mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 10:07:02 +00:00
Compare commits
292 Commits
v2.1.0-bet
...
v3.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0956a13618 | ||
|
|
de4bb991dd | ||
|
|
14f0895ae7 | ||
|
|
8bb01570ef | ||
|
|
3a1c757d04 | ||
|
|
d8474d56e5 | ||
|
|
eef45a4473 | ||
|
|
91d19308fe | ||
|
|
e359c0b030 | ||
|
|
a73ffeabd3 | ||
|
|
0b3f4eab75 | ||
|
|
7ea4798e77 | ||
|
|
070de88bba | ||
|
|
383386d5fb | ||
|
|
a3e2c5247e | ||
|
|
702f9ef48e | ||
|
|
ac4501ba35 | ||
|
|
c78656b400 | ||
|
|
b6a6cc6708 | ||
|
|
a17fa256a2 | ||
|
|
7ea8e2fc03 | ||
|
|
a0b6f0692d | ||
|
|
c1b7e3605c | ||
|
|
2b3dd51e71 | ||
|
|
e4c48a0705 | ||
|
|
5c885a067a | ||
|
|
71b3de59af | ||
|
|
fc95f6e57f | ||
|
|
e5e1c39097 | ||
|
|
f1b85be23a | ||
|
|
aaf8eb5ec1 | ||
|
|
18b85877ab | ||
|
|
cd89d77d9f | ||
|
|
a54e0f2438 | ||
|
|
3738ccc11d | ||
|
|
a6cdd30fb1 | ||
|
|
a467829103 | ||
|
|
b2255fefab | ||
|
|
34799b9a04 | ||
|
|
cfbd30d8b0 | ||
|
|
7cd45ff3c7 | ||
|
|
3c2b302a5f | ||
|
|
35969e0b0f | ||
|
|
d933d591d8 | ||
|
|
b82cafc338 | ||
|
|
f739ba90a1 | ||
|
|
6724c86181 | ||
|
|
645feeaf85 | ||
|
|
99df27ee34 | ||
|
|
5ae69314dd | ||
|
|
3df655d611 | ||
|
|
ae76ecef00 | ||
|
|
37f05b08c5 | ||
|
|
79fdc58567 | ||
|
|
d16699f59f | ||
|
|
9ca179e249 | ||
|
|
07e75495e8 | ||
|
|
683ce1241e | ||
|
|
9815a66575 | ||
|
|
8e04ce1fec | ||
|
|
6287f5fe9c | ||
|
|
f47e2cfcc2 | ||
|
|
dbdc60a0fb | ||
|
|
c0a878db47 | ||
|
|
0ea18cbe2b | ||
|
|
986377b531 | ||
|
|
fac44b7753 | ||
|
|
c977ded5ba | ||
|
|
c2109a8df0 | ||
|
|
321c5615a5 | ||
|
|
c57260349b | ||
|
|
91f3e08ce5 | ||
|
|
c762c4d7a1 | ||
|
|
b954bea7c6 | ||
|
|
362ece171a | ||
|
|
1922dc145d | ||
|
|
4b9e432730 | ||
|
|
78b36b0b14 | ||
|
|
2f7194835d | ||
|
|
7c213cd897 | ||
|
|
6189b56b79 | ||
|
|
1a8b5184cd | ||
|
|
55e11929c7 | ||
|
|
4dd3ad33f9 | ||
|
|
92c89b98ee | ||
|
|
51d732fa20 | ||
|
|
ffaf296faa | ||
|
|
af3ed04100 | ||
|
|
caec5e7c17 | ||
|
|
862aff434e | ||
|
|
6ef2beb821 | ||
|
|
6fd13a5215 | ||
|
|
77ea66e0e6 | ||
|
|
89886843bd | ||
|
|
4894244d5c | ||
|
|
d627da2038 | ||
|
|
348eebe418 | ||
|
|
bc427e237f | ||
|
|
f12a72871f | ||
|
|
efe554bd77 | ||
|
|
ecd7c9f6e6 | ||
|
|
f0c9a1e7f4 | ||
|
|
1eba3164b5 | ||
|
|
aae41ab79a | ||
|
|
fc5cc4d864 | ||
|
|
9b3049562d | ||
|
|
7a16f64ff0 | ||
|
|
6a95dfe5c6 | ||
|
|
22884378f3 | ||
|
|
0cf97f5c58 | ||
|
|
4eb2d5ae97 | ||
|
|
ce59c87250 | ||
|
|
6215ce77dd | ||
|
|
ba8e2de475 | ||
|
|
0f9585a52b | ||
|
|
e71377f966 | ||
|
|
0c9cf81c94 | ||
|
|
ff7fb8a781 | ||
|
|
6619764ea2 | ||
|
|
0d9952d35f | ||
|
|
5be2b57a12 | ||
|
|
0ed0cd2d64 | ||
|
|
74e7fd1179 | ||
|
|
eaf41949d4 | ||
|
|
59062e1326 | ||
|
|
019bd11309 | ||
|
|
cf265dbe2c | ||
|
|
c77cae2429 | ||
|
|
abd9ebeb35 | ||
|
|
f2cd3f92da | ||
|
|
e70900dd1a | ||
|
|
284dd70bc6 | ||
|
|
fe20854173 | ||
|
|
5ccdece541 | ||
|
|
b67d5ba376 | ||
|
|
ecd0c124d4 | ||
|
|
ac3a94412d | ||
|
|
8017e0ce57 | ||
|
|
1f2d1a4622 | ||
|
|
bea3849c97 | ||
|
|
2bbcef072a | ||
|
|
d1954cdd6f | ||
|
|
c92cd309bc | ||
|
|
d05eb10851 | ||
|
|
9e2f138279 | ||
|
|
af9c735cd7 | ||
|
|
5328406533 | ||
|
|
54ca36c442 | ||
|
|
5ab273b7b8 | ||
|
|
352e8c396d | ||
|
|
f0b4dda8e6 | ||
|
|
c8dabf8593 | ||
|
|
852b9fce26 | ||
|
|
a8795f46dc | ||
|
|
bcf0cafb34 | ||
|
|
04a3f58e6d | ||
|
|
7dade7f0e4 | ||
|
|
523c04a522 | ||
|
|
7a5459ce08 | ||
|
|
dd14fb9989 | ||
|
|
c5b5321be3 | ||
|
|
8b5a05eb40 | ||
|
|
60db73b813 | ||
|
|
ef633b906c | ||
|
|
2e49b591eb |
@@ -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 }}
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -21,11 +21,16 @@ _testmain.go
|
||||
|
||||
*.exe
|
||||
|
||||
# vim swp files
|
||||
# vim files
|
||||
*.swp
|
||||
*.swo
|
||||
tags
|
||||
tags.*
|
||||
|
||||
# our binaries
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
|
||||
# Inbucket binaries
|
||||
/client
|
||||
/client.exe
|
||||
/inbucket
|
||||
@@ -35,3 +40,16 @@ _testmain.go
|
||||
/cmd/client/client.exe
|
||||
/cmd/inbucket/inbucket
|
||||
/cmd/inbucket/inbucket.exe
|
||||
|
||||
# Elm UI
|
||||
# elm-package generated files
|
||||
/ui/index.html
|
||||
/ui/elm-stuff
|
||||
/ui/tests/elm-stuff
|
||||
# elm-repl generated files
|
||||
repl-temp-*
|
||||
# Distribution
|
||||
/ui/dist/
|
||||
# Dependency directories
|
||||
/ui/node_modules
|
||||
/ui/.parcel-cache
|
||||
|
||||
@@ -2,18 +2,19 @@ project_name: inbucket
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: jhillyerd
|
||||
owner: inbucket
|
||||
name: inbucket
|
||||
name_template: '{{.Tag}}'
|
||||
|
||||
brew:
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
install: bin.install ""
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
- binary: inbucket
|
||||
- id: inbucket
|
||||
binary: inbucket
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
@@ -21,11 +22,16 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/inbucket
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
- binary: inbucket-client
|
||||
- id: inbucket-client
|
||||
binary: inbucket-client
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
@@ -33,40 +39,48 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/client
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
|
||||
archive:
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||
.Arm }}{{ end }}'
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- README*
|
||||
- CHANGELOG*
|
||||
- etc/**/*
|
||||
- ui/**/*
|
||||
archives:
|
||||
- id: tarball
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||
.Arm }}{{ end }}'
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- README*
|
||||
- CHANGELOG*
|
||||
- etc/**
|
||||
- ui/dist/**
|
||||
- ui/greeting.html
|
||||
|
||||
nfpm:
|
||||
vendor: inbucket.org
|
||||
homepage: https://www.inbucket.org/
|
||||
maintainer: github@hillyerd.com
|
||||
description: All-in-one disposable webmail service.
|
||||
license: MIT
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
files:
|
||||
"ui/**/*": "/usr/local/share/inbucket/ui"
|
||||
config_files:
|
||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||
nfpms:
|
||||
- formats:
|
||||
- deb
|
||||
- rpm
|
||||
vendor: inbucket.org
|
||||
homepage: https://www.inbucket.org/
|
||||
maintainer: github@hillyerd.com
|
||||
description: All-in-one disposable webmail service.
|
||||
license: MIT
|
||||
contents:
|
||||
- src: "ui/dist/**"
|
||||
dst: "/usr/local/share/inbucket/ui"
|
||||
- src: "etc/linux/inbucket.service"
|
||||
dst: "/lib/systemd/system/inbucket.service"
|
||||
type: config|noreplace
|
||||
- src: "ui/greeting.html"
|
||||
dst: "/etc/inbucket/greeting.html"
|
||||
type: config|noreplace
|
||||
|
||||
snapshot:
|
||||
name_template: SNAPSHOT-{{ .Commit }}
|
||||
@@ -75,6 +89,3 @@ checksum:
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||
|
||||
dist: dist
|
||||
|
||||
sign:
|
||||
artifacts: none
|
||||
|
||||
26
.travis.yml
26
.travis.yml
@@ -1,26 +0,0 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- rpm
|
||||
|
||||
env:
|
||||
global:
|
||||
- GO111MODULE=on
|
||||
- DEPLOY_WITH_MAJOR="1.11"
|
||||
|
||||
before_script:
|
||||
- go get golang.org/x/lint/golint
|
||||
- make deps
|
||||
|
||||
go:
|
||||
- "1.10.x"
|
||||
- "1.11.x"
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
190
CHANGELOG.md
190
CHANGELOG.md
@@ -4,8 +4,139 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v2.1.0-beta1]
|
||||
|
||||
## [v3.0.3] - 2022-08-07
|
||||
|
||||
### Fixed
|
||||
- Support for `AUTH=<>` FROM parameter (#284)
|
||||
|
||||
|
||||
## [v3.0.2] - 2022-07-04
|
||||
|
||||
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
|
||||
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
|
||||
details.
|
||||
|
||||
### Changed
|
||||
- arm Docker builds now rely on amd64 frontend build stage
|
||||
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
|
||||
|
||||
|
||||
## [v3.0.1-rc2] - 2022-01-23
|
||||
|
||||
### Added
|
||||
- Builds for arm7 and arm64 platforms
|
||||
|
||||
### Changed
|
||||
- Abandoned git-flow process, the `master` branch renamed to `main`
|
||||
|
||||
|
||||
## [v3.0.1-rc1] - 2022-01-17
|
||||
|
||||
### Fixed
|
||||
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
|
||||
|
||||
### Changed
|
||||
- Update Go dependencies
|
||||
- Update NPM dependencies
|
||||
|
||||
|
||||
## [v3.0.0] - 2021-09-19
|
||||
|
||||
Unchanged from rc4.
|
||||
|
||||
|
||||
## [v3.0.0-rc4] - 2021-08-22
|
||||
|
||||
### Fixed
|
||||
- Various MIME header decoding improvements
|
||||
|
||||
### Changed
|
||||
- Bump Go version to 1.17 (#233)
|
||||
|
||||
|
||||
## v3.0.0-rc3 - 2021-08-01
|
||||
|
||||
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
|
||||
tags for Docker Hub and ghcr.io.
|
||||
|
||||
|
||||
## [v3.0.0-rc2] - 2021-07-31
|
||||
|
||||
### Added
|
||||
- Support for SMTP AUTH (#197, thanks makarchuk)
|
||||
- Dark mode support (#218, thanks nerones)
|
||||
|
||||
### Fixed
|
||||
- Prevent potential click jacking (#190, thanks stuartskelton)
|
||||
- Error on 8 character long SMTP commands (#221)
|
||||
- Allow empty username and password during AUTH (#225)
|
||||
|
||||
|
||||
## [v3.0.0-rc1] - 2020-09-24
|
||||
|
||||
### Added
|
||||
- Refresh button to reload mailbox contents
|
||||
- Improved keyboard (tab) focus highlights
|
||||
|
||||
### Changed
|
||||
- The UI now includes the Open Sans webfont instead of relying on browser/OS
|
||||
fonts
|
||||
|
||||
|
||||
## [v3.0.0-beta3] - 2020-09-04
|
||||
|
||||
### Added
|
||||
- Docker `HEALTHCHECK`
|
||||
- Mouse-out delay to improve pop-up menu navigation
|
||||
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||
|
||||
### Changed
|
||||
- Updated frontend and backend dependencies, Docker image base
|
||||
|
||||
### Fixed
|
||||
- Improved layout on mobile and wide displays
|
||||
- Prevent unexpected input for modal dialogs
|
||||
- Allow empty SMTP `MAIL FROM:<>`
|
||||
|
||||
|
||||
## [v3.0.0-beta2] - 2019-08-17
|
||||
|
||||
### Added
|
||||
- Ability to name mailboxes after domain of email recipient, set via
|
||||
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
|
||||
|
||||
### Changed
|
||||
- Updated JavaScript dependencies.
|
||||
- Updated Go dependencies.
|
||||
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
|
||||
|
||||
### Fixed
|
||||
- URLs to view/download attachments from REST API, #138
|
||||
- Support for late EHLO, #141
|
||||
|
||||
|
||||
## [v3.0.0-beta1] - 2019-03-14
|
||||
|
||||
### Added
|
||||
- `posix-millis` field to REST message and header responses for easier date
|
||||
parsing.
|
||||
|
||||
### Changed
|
||||
- Rewrote the user interface from scratch, it's now an Elm powered single page
|
||||
application.
|
||||
- Moved the Inbucket repository to its own GitHub organization.
|
||||
- Update to enmime v0.5.0
|
||||
|
||||
|
||||
## v2.1.0 - 2018-12-15
|
||||
|
||||
No change from beta1.
|
||||
|
||||
|
||||
## [v2.1.0-beta1] - 2018-10-31
|
||||
|
||||
### Added
|
||||
- Use Go 1.11 modules for reproducible builds.
|
||||
@@ -98,7 +229,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||
provides a more natural API
|
||||
- Powerful command line REST
|
||||
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
|
||||
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
|
||||
- Allow use of `latest` as a message ID in REST calls
|
||||
|
||||
### Changed
|
||||
@@ -113,9 +244,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Added
|
||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||
- Attachment list to [GET message
|
||||
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
|
||||
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
|
||||
- [Go client for REST
|
||||
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
|
||||
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
|
||||
- Monitor feature: lists messages as they arrive, regardless of their
|
||||
destination mailbox
|
||||
- Make `@inbucket` mailbox prompt configurable
|
||||
@@ -178,33 +309,46 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.3...main
|
||||
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
|
||||
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
|
||||
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
|
||||
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
|
||||
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
|
||||
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
|
||||
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
|
||||
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
|
||||
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
|
||||
## Release Checklist
|
||||
|
||||
1. Create release branch: `git flow release start 1.x.0`
|
||||
1. Create a release branch
|
||||
2. Update CHANGELOG.md:
|
||||
- Ensure *Unreleased* section is up to date
|
||||
- Rename *Unreleased* section to release name and date.
|
||||
- Rename *Unreleased* section to release name and date
|
||||
- Add new GitHub `/compare` link
|
||||
- Update previous tag version for *Unreleased*
|
||||
3. Run tests
|
||||
4. Test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release: `git flow release finish`
|
||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
||||
complete
|
||||
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||
6. Push tags and wait for
|
||||
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
|
||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
|
||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||
|
||||
@@ -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
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,8 +1,18 @@
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
# Build
|
||||
FROM golang:1.11-alpine3.8 as builder
|
||||
RUN apk add --no-cache --virtual .build-deps git make
|
||||
### Build frontend
|
||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||
FROM --platform=linux/amd64 node:16 as frontend
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf .parcel-cache dist elm-stuff node_modules
|
||||
RUN yarn install --frozen-lockfile --non-interactive
|
||||
RUN yarn run build
|
||||
|
||||
### Build backend
|
||||
FROM golang:1.18-alpine3.16 as backend
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
@@ -11,13 +21,14 @@ RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
# Run in minimal image
|
||||
FROM alpine:3.8
|
||||
### Run in minimal image
|
||||
FROM alpine:3.16
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=builder /build/inbucket bin
|
||||
COPY --from=backend /build/inbucket bin
|
||||
COPY --from=frontend /build/ui/dist ui
|
||||
COPY etc/docker/defaults/greeting.html defaults
|
||||
COPY ui ui
|
||||
COPY etc/docker/defaults/start-inbucket.sh /
|
||||
|
||||
# Configuration
|
||||
@@ -26,11 +37,15 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||
ENV INBUCKET_WEB_UIDIR=ui
|
||||
ENV INBUCKET_STORAGE_TYPE file
|
||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
|
||||
|
||||
# Ports: SMTP, HTTP, POP3
|
||||
EXPOSE 2500 9000 1100
|
||||
|
||||
|
||||
85
README.md
85
README.md
@@ -1,19 +1,21 @@
|
||||
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/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
|
||||
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
||||
|
||||
Read more at the [Inbucket Website]
|
||||
|
||||

|
||||
|
||||
|
||||
## Development Status
|
||||
|
||||
Inbucket is currently production quality: it is being used for real work.
|
||||
@@ -22,49 +24,66 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
|
||||
to contribute code to the project check out [CONTRIBUTING.md].
|
||||
|
||||
|
||||
## Homebrew Tap
|
||||
## Docker
|
||||
|
||||
(currently broken, being tracked in [issue
|
||||
#68](https://github.com/jhillyerd/inbucket/issues/68))
|
||||
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||
`main` branch.
|
||||
|
||||
|
||||
## Building from Source
|
||||
|
||||
You will need a functioning [Go installation][Google Go] for this to work.
|
||||
You will need functioning [Go] and [Node.js] installations for this to work.
|
||||
|
||||
Grab the Inbucket source code and compile the daemon:
|
||||
```sh
|
||||
git clone https://github.com/inbucket/inbucket.git
|
||||
cd inbucket/ui
|
||||
yarn install
|
||||
yarn build
|
||||
cd ..
|
||||
go build ./cmd/inbucket
|
||||
```
|
||||
|
||||
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
|
||||
For more information on building and development flows, check out the
|
||||
[Development Quickstart] page of our wiki.
|
||||
|
||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||
Unix and OS X machines as is. Launch the daemon:
|
||||
### Configure and Launch
|
||||
|
||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
||||
Inbucket reads its configuration from environment variables, but comes with
|
||||
reasonable defaults built-in. It should work on most Unix and OS X machines as
|
||||
is. Launch the daemon:
|
||||
|
||||
```sh
|
||||
./inbucket
|
||||
```
|
||||
|
||||
By default the SMTP server will be listening on localhost port 2500 and
|
||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
|
||||
The Inbucket website has a more complete guide to
|
||||
[installing from source][From Source]
|
||||
See doc/[config.md] for more information on configuring Inbucket, but you will
|
||||
likely find the [Configurator] tool the easiest way to generate a configuration.
|
||||
|
||||
|
||||
## About
|
||||
|
||||
Inbucket is written in [Google Go]
|
||||
Inbucket is written in [Go] and [Elm].
|
||||
|
||||
Inbucket is open source software released under the MIT License. The latest
|
||||
version can be found at https://github.com/jhillyerd/inbucket
|
||||
version can be found at https://github.com/inbucket/inbucket
|
||||
|
||||
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
|
||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||
[Google Go]: http://golang.org/
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
||||
[Inbucket Website]: http://www.inbucket.org/
|
||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||
[Configurator]: https://www.inbucket.org/configurator/
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
|
||||
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
|
||||
[Docker Image]: https://www.inbucket.org/binaries/docker.html
|
||||
[Elm]: https://elm-lang.org/
|
||||
[From Source]: https://www.inbucket.org/installation/from-source.html
|
||||
[Go]: https://golang.org/
|
||||
[Go API docs]: https://pkg.go.dev/github.com/inbucket/inbucket/pkg/rest/client
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||
[Inbucket Website]: https://www.inbucket.org/
|
||||
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||
[Node.js]: https://nodejs.org/en/
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type matchCmd struct {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type mboxCmd struct {
|
||||
|
||||
@@ -14,18 +14,19 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/pop3"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/file"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -40,10 +41,8 @@ var (
|
||||
|
||||
func init() {
|
||||
// Server uptime for status page.
|
||||
startTime := time.Now()
|
||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
||||
return time.Since(startTime) / time.Second
|
||||
}))
|
||||
startTime := expvar.NewInt("startMillis")
|
||||
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||
|
||||
// Goroutine count for status page.
|
||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||
@@ -73,6 +72,7 @@ func main() {
|
||||
config.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// Process configuration.
|
||||
config.Version = version
|
||||
config.BuildDate = date
|
||||
@@ -85,6 +85,7 @@ func main() {
|
||||
conf.POP3.Debug = true
|
||||
conf.SMTP.Debug = true
|
||||
}
|
||||
|
||||
// Logger setup.
|
||||
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||
if err != nil {
|
||||
@@ -92,12 +93,15 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
startupLog := log.With().Str("phase", "startup").Logger()
|
||||
|
||||
// Setup signal handler.
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
// Initialize logging.
|
||||
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||
Msg("Inbucket starting")
|
||||
|
||||
// Write pidfile if requested.
|
||||
if *pidfile != "" {
|
||||
pidf, err := os.Create(*pidfile)
|
||||
@@ -109,6 +113,7 @@ func main() {
|
||||
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure internal services.
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
shutdownChan := make(chan bool)
|
||||
@@ -120,20 +125,26 @@ func main() {
|
||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
|
||||
// Start Retention scanner.
|
||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||
retentionScanner.Start()
|
||||
// Start HTTP server.
|
||||
|
||||
// Configure routes and start HTTP server.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
go web.Start(rootCtx)
|
||||
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel.
|
||||
signalLoop:
|
||||
for {
|
||||
@@ -156,6 +167,7 @@ signalLoop:
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish.
|
||||
go timedExit(*pidfile)
|
||||
smtpServer.Drain()
|
||||
|
||||
@@ -28,11 +28,9 @@ variables it supports:
|
||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||
INBUCKET_WEB_UIDIR ui User interface dir
|
||||
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
|
||||
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
|
||||
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
|
||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
||||
@@ -79,8 +77,14 @@ Prior to the addition of the mailbox naming setting, Inbucket always operated in
|
||||
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||
incorporated into the mailbox name.
|
||||
|
||||
#### `domain` ensures the local-part is removed, such that:
|
||||
|
||||
- `james@inbucket.org` is stored in `inbucket.org`
|
||||
- `matt@inbucket.org` is stored in `inbucket.org`
|
||||
- `matt@noinbucket.com` is stored in `notinbucket.com`
|
||||
|
||||
- Default: `local`
|
||||
- Values: one of `local` or `full`
|
||||
- Values: one of `local` or `full` or `domain`
|
||||
|
||||
|
||||
## SMTP
|
||||
@@ -228,7 +232,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
|
||||
### TLS Public Certificate File
|
||||
|
||||
`INBUCKET_SMTP_TLSPRIVKEY`
|
||||
`INBUCKET_SMTP_TLSCERT`
|
||||
|
||||
Specify the x509 Certificate file to be used for TLS negotiation.
|
||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
@@ -287,6 +291,24 @@ Inbucket to listen on all available network interfaces.
|
||||
|
||||
- Default: `0.0.0.0:9000`
|
||||
|
||||
### Base Path
|
||||
|
||||
`INBUCKET_WEB_BASEPATH`
|
||||
|
||||
Base path prefix for UI and API URLs. This option is used when you wish to
|
||||
root all Inbucket URLs to a specific path when placing it behind a
|
||||
reverse-proxy.
|
||||
|
||||
For example, setting the base path to `prefix` will move:
|
||||
- the Inbucket status page from `/status` to `/prefix/status`,
|
||||
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
|
||||
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
|
||||
|
||||
*Note:* This setting will not work correctly when running Inbucket via the npm
|
||||
development server.
|
||||
|
||||
- Default: None
|
||||
|
||||
### UI Directory
|
||||
|
||||
`INBUCKET_WEB_UIDIR`
|
||||
@@ -298,7 +320,7 @@ doesn't contain the `ui` directory at startup.
|
||||
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||
static assets from the `static` sub-directory.
|
||||
|
||||
- Default: `ui`
|
||||
- Default: `ui/dist`
|
||||
- Values: Operating system specific path syntax
|
||||
|
||||
### Greeting HTML File
|
||||
@@ -311,39 +333,6 @@ Inbucket installation, as well as link to REST documentation, etc.
|
||||
|
||||
- Default: `ui/greeting.html`
|
||||
|
||||
### Template Caching
|
||||
|
||||
`INBUCKET_WEB_TEMPLATECACHE`
|
||||
|
||||
Tells Inbucket to cache parsed template files. This should be left as default
|
||||
unless you are a developer working on the Inbucket web interface.
|
||||
|
||||
- Default: `true`
|
||||
- Values: `true` or `false`
|
||||
|
||||
### Mailbox Prompt
|
||||
|
||||
`INBUCKET_WEB_MAILBOXPROMPT`
|
||||
|
||||
Text prompt displayed to the right of the mailbox name input field in the web
|
||||
interface. Can be used to nudge your users into typing just the mailbox name
|
||||
instead of an entire email address.
|
||||
|
||||
Set to an empty string to hide the prompt.
|
||||
|
||||
- Default: `@inbucket`
|
||||
|
||||
### Cookie Authentication Key
|
||||
|
||||
`INBUCKET_WEB_COOKIEAUTHKEY`
|
||||
|
||||
Inbucket stores session information in an encrypted browser cookie. Unless
|
||||
specified, Inbucket generates a random key at startup. The only notable data
|
||||
stored in a user session is the list of recently accessed mailboxes.
|
||||
|
||||
- Default: None
|
||||
- Value: Text string, no particular format required
|
||||
|
||||
### Monitor Visible
|
||||
|
||||
`INBUCKET_WEB_MONITORVISIBLE`
|
||||
|
||||
@@ -3,16 +3,32 @@
|
||||
# description: Developer friendly Inbucket configuration
|
||||
|
||||
export INBUCKET_LOGLEVEL="debug"
|
||||
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||
#export INBUCKET_SMTP_DEFAULTSTORE="false"
|
||||
export INBUCKET_SMTP_STOREDOMAINS="important.local"
|
||||
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||
#export INBUCKET_WEB_BASEPATH="prefix"
|
||||
export INBUCKET_STORAGE_TYPE="file"
|
||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
|
||||
|
||||
if ! test -x ./inbucket; then
|
||||
echo "$PWD/inbucket not found/executable!" >&2
|
||||
echo "Run this script from the inbucket root directory after running make" >&2
|
||||
echo "Run this script from the inbucket root directory after running make." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||
if ! test -f "$index"; then
|
||||
echo "$index does not exist!" >&2
|
||||
echo "Run 'npm run build' from the 'ui' directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
31
etc/swaks-tests/mime-errors.raw
Normal file
31
etc/swaks-tests/mime-errors.raw
Normal file
@@ -0,0 +1,31 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: MIME Errors
|
||||
Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
|
||||
Content-Type: multipart/alternative; boundary="Enmime-Test-100"
|
||||
|
||||
--Enmime-Test-100
|
||||
Content-Transfer-Encoding: 8bit
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
|
||||
Using Unicode/UTF-8, you can write in emails and source code things such as
|
||||
|
||||
Mathematics and sciences:
|
||||
|
||||
∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ⎧⎡⎛┌─────┐⎞⎤⎫
|
||||
⎪⎢⎜│a²+b³ ⎟⎥⎪
|
||||
∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β), ⎪⎢⎜│───── ⎟⎥⎪
|
||||
⎪⎢⎜⎷ c₈ ⎟⎥⎪
|
||||
ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ, ⎨⎢⎜ ⎟⎥⎬
|
||||
⎪⎢⎜ ∞ ⎟⎥⎪
|
||||
⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫), ⎪⎢⎜ ⎲ ⎟⎥⎪
|
||||
⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
|
||||
2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm ⎩⎣⎝i=1 ⎠⎦⎭
|
||||
|
||||
Linguistics and dictionaries:
|
||||
|
||||
ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
|
||||
Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
|
||||
|
||||
--Enmime-Test-100
|
||||
@@ -24,7 +24,7 @@ case "$1" in
|
||||
;;
|
||||
esac
|
||||
|
||||
export SWAKS_OPT_server="127.0.0.1:2500"
|
||||
export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
|
||||
export SWAKS_OPT_to="$to@inbucket.local"
|
||||
|
||||
# Basic test
|
||||
@@ -56,3 +56,6 @@ swaks $* --data outlook.raw
|
||||
# Non-mime responsive HTML test
|
||||
swaks $* --data nonmime-html-responsive.raw
|
||||
swaks $* --data nonmime-html-inlined.raw
|
||||
|
||||
# Incorrect charset, malformed final boundary
|
||||
swaks $* --data mime-errors.raw
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# travis-deploy.sh
|
||||
# description: Trigger goreleaser deployment in correct build scenarios
|
||||
|
||||
set -eo pipefail
|
||||
set -x
|
||||
|
||||
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
|
||||
curl -sL https://git.io/goreleaser | bash
|
||||
fi
|
||||
30
go.mod
30
go.mod
@@ -1,19 +1,23 @@
|
||||
module github.com/jhillyerd/inbucket
|
||||
module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.6.2
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.1.3
|
||||
github.com/gorilla/websocket v1.4.0
|
||||
github.com/jhillyerd/enmime v0.2.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/jhillyerd/enmime v0.9.2
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.1
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/zerolog v1.9.1
|
||||
github.com/stretchr/testify v1.2.2
|
||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.17
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
|
||||
111
go.sum
111
go.sum
@@ -1,42 +1,91 @@
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
|
||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
|
||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
|
||||
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
|
||||
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
|
||||
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
||||
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
|
||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
|
||||
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
|
||||
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
|
||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
UnknownNaming mbNaming = iota
|
||||
LocalNaming
|
||||
FullNaming
|
||||
DomainNaming
|
||||
)
|
||||
|
||||
// Decode a naming strategy from string.
|
||||
@@ -47,6 +48,8 @@ func (n *mbNaming) Decode(v string) error {
|
||||
*n = LocalNaming
|
||||
case "full":
|
||||
*n = FullNaming
|
||||
case "domain":
|
||||
*n = DomainNaming
|
||||
default:
|
||||
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func (n *mbNaming) Decode(v string) error {
|
||||
// Root contains global configuration, and structs with for specific sub-systems.
|
||||
type Root struct {
|
||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
|
||||
SMTP SMTP
|
||||
POP3 POP3
|
||||
Web Web
|
||||
@@ -93,11 +96,9 @@ type POP3 struct {
|
||||
// Web contains the HTTP server configuration.
|
||||
type Web struct {
|
||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
|
||||
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
|
||||
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
|
||||
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
|
||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
|
||||
broadcast := msghub.Message{
|
||||
Mailbox: to.Mailbox,
|
||||
ID: id,
|
||||
From: delivery.From().String(),
|
||||
From: stringutil.StringAddress(delivery.From()),
|
||||
To: stringutil.StringAddressList(delivery.To()),
|
||||
Subject: delivery.Subject(),
|
||||
Date: delivery.Date(),
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Metadata holds information about a message, but not the content.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
// Addressing handles email address policy.
|
||||
@@ -28,6 +28,20 @@ func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
||||
if a.Config.MailboxNaming == config.LocalNaming {
|
||||
return local, nil
|
||||
}
|
||||
if a.Config.MailboxNaming == config.DomainNaming {
|
||||
// If no domain is specified, assume this is being
|
||||
// used for mailbox lookup via the API.
|
||||
if domain == "" {
|
||||
if ValidateDomainPart(local) == false {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
if ValidateDomainPart(domain) == false {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
if a.Config.MailboxNaming != config.FullNaming {
|
||||
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
||||
}
|
||||
@@ -128,8 +142,8 @@ func ValidateDomainPart(domain string) bool {
|
||||
hasAlphaNum = true
|
||||
labelLen++
|
||||
case c == '-':
|
||||
if prev == '.' {
|
||||
// Cannot lead with hyphen.
|
||||
if prev == '.' || prev == '-' {
|
||||
// Cannot lead with hyphen or double hyphen.
|
||||
return false
|
||||
}
|
||||
case c == '.':
|
||||
@@ -159,16 +173,16 @@ func ValidateDomainPart(domain string) bool {
|
||||
// domain part is optional and not validated.
|
||||
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||
if address == "" {
|
||||
return "", "", fmt.Errorf("Empty address")
|
||||
return "", "", fmt.Errorf("empty address")
|
||||
}
|
||||
if len(address) > 320 {
|
||||
return "", "", fmt.Errorf("Address exceeds 320 characters")
|
||||
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||
}
|
||||
if address[0] == '@' {
|
||||
return "", "", fmt.Errorf("Address cannot start with @ symbol")
|
||||
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
||||
}
|
||||
if address[0] == '.' {
|
||||
return "", "", fmt.Errorf("Address cannot start with a period")
|
||||
return "", "", fmt.Errorf("address cannot start with a period")
|
||||
}
|
||||
// Loop over address parsing out local part.
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
)
|
||||
|
||||
func TestShouldAcceptDomain(t *testing.T) {
|
||||
@@ -125,111 +125,139 @@ func TestShouldStoreDomain(t *testing.T) {
|
||||
func TestExtractMailboxValid(t *testing.T) {
|
||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||
|
||||
testTable := []struct {
|
||||
input string // Input to test
|
||||
local string // Expected output when mailbox naming = local
|
||||
full string // Expected output when mailbox naming = full
|
||||
input string // Input to test
|
||||
local string // Expected output when mailbox naming = local
|
||||
full string // Expected output when mailbox naming = full
|
||||
domain string // Expected output when mailbox naming = domain
|
||||
}{
|
||||
{
|
||||
input: "mailbox",
|
||||
local: "mailbox",
|
||||
full: "mailbox",
|
||||
input: "mailbox",
|
||||
local: "mailbox",
|
||||
full: "mailbox",
|
||||
domain: "mailbox",
|
||||
},
|
||||
{
|
||||
input: "user123",
|
||||
local: "user123",
|
||||
full: "user123",
|
||||
input: "user123",
|
||||
local: "user123",
|
||||
full: "user123",
|
||||
domain: "user123",
|
||||
},
|
||||
{
|
||||
input: "MailBOX",
|
||||
local: "mailbox",
|
||||
full: "mailbox",
|
||||
input: "MailBOX",
|
||||
local: "mailbox",
|
||||
full: "mailbox",
|
||||
domain: "mailbox",
|
||||
},
|
||||
{
|
||||
input: "First.Last",
|
||||
local: "first.last",
|
||||
full: "first.last",
|
||||
input: "First.Last",
|
||||
local: "first.last",
|
||||
full: "first.last",
|
||||
domain: "first.last",
|
||||
},
|
||||
{
|
||||
input: "user+label",
|
||||
local: "user",
|
||||
full: "user",
|
||||
input: "user+label",
|
||||
local: "user",
|
||||
full: "user",
|
||||
domain: "user",
|
||||
},
|
||||
{
|
||||
input: "chars!#$%",
|
||||
local: "chars!#$%",
|
||||
full: "chars!#$%",
|
||||
input: "chars!#$%",
|
||||
local: "chars!#$%",
|
||||
full: "chars!#$%",
|
||||
domain: "",
|
||||
},
|
||||
{
|
||||
input: "chars&'*-",
|
||||
local: "chars&'*-",
|
||||
full: "chars&'*-",
|
||||
input: "chars&'*-",
|
||||
local: "chars&'*-",
|
||||
full: "chars&'*-",
|
||||
domain: "",
|
||||
},
|
||||
{
|
||||
input: "chars=/?^",
|
||||
local: "chars=/?^",
|
||||
full: "chars=/?^",
|
||||
input: "chars=/?^",
|
||||
local: "chars=/?^",
|
||||
full: "chars=/?^",
|
||||
domain: "",
|
||||
},
|
||||
{
|
||||
input: "chars_`.{",
|
||||
local: "chars_`.{",
|
||||
full: "chars_`.{",
|
||||
input: "chars_`.{",
|
||||
local: "chars_`.{",
|
||||
full: "chars_`.{",
|
||||
domain: "",
|
||||
},
|
||||
{
|
||||
input: "chars|}~",
|
||||
local: "chars|}~",
|
||||
full: "chars|}~",
|
||||
input: "chars|}~",
|
||||
local: "chars|}~",
|
||||
full: "chars|}~",
|
||||
domain: "",
|
||||
},
|
||||
{
|
||||
input: "mailbox@domain.com",
|
||||
local: "mailbox",
|
||||
full: "mailbox@domain.com",
|
||||
input: "mailbox@domain.com",
|
||||
local: "mailbox",
|
||||
full: "mailbox@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "user123@domain.com",
|
||||
local: "user123",
|
||||
full: "user123@domain.com",
|
||||
input: "user123@domain.com",
|
||||
local: "user123",
|
||||
full: "user123@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "MailBOX@domain.com",
|
||||
local: "mailbox",
|
||||
full: "mailbox@domain.com",
|
||||
input: "MailBOX@domain.com",
|
||||
local: "mailbox",
|
||||
full: "mailbox@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "First.Last@domain.com",
|
||||
local: "first.last",
|
||||
full: "first.last@domain.com",
|
||||
input: "First.Last@domain.com",
|
||||
local: "first.last",
|
||||
full: "first.last@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "user+label@domain.com",
|
||||
local: "user",
|
||||
full: "user@domain.com",
|
||||
input: "user+label@domain.com",
|
||||
local: "user",
|
||||
full: "user@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars!#$%@domain.com",
|
||||
local: "chars!#$%",
|
||||
full: "chars!#$%@domain.com",
|
||||
input: "chars!#$%@domain.com",
|
||||
local: "chars!#$%",
|
||||
full: "chars!#$%@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars&'*-@domain.com",
|
||||
local: "chars&'*-",
|
||||
full: "chars&'*-@domain.com",
|
||||
input: "chars&'*-@domain.com",
|
||||
local: "chars&'*-",
|
||||
full: "chars&'*-@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars=/?^@domain.com",
|
||||
local: "chars=/?^",
|
||||
full: "chars=/?^@domain.com",
|
||||
input: "chars=/?^@domain.com",
|
||||
local: "chars=/?^",
|
||||
full: "chars=/?^@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars_`.{@domain.com",
|
||||
local: "chars_`.{",
|
||||
full: "chars_`.{@domain.com",
|
||||
input: "chars_`.{@domain.com",
|
||||
local: "chars_`.{",
|
||||
full: "chars_`.{@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars|}~@domain.com",
|
||||
local: "chars|}~",
|
||||
full: "chars|}~@domain.com",
|
||||
input: "chars|}~@domain.com",
|
||||
local: "chars|}~",
|
||||
full: "chars|}~@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "chars|}~@example.co.uk",
|
||||
local: "chars|}~",
|
||||
full: "chars|}~@example.co.uk",
|
||||
domain: "example.co.uk",
|
||||
},
|
||||
}
|
||||
for _, tc := range testTable {
|
||||
@@ -247,12 +275,20 @@ func TestExtractMailboxValid(t *testing.T) {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
||||
}
|
||||
}
|
||||
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
|
||||
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
|
||||
} else {
|
||||
if result != tc.domain {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMailboxInvalid(t *testing.T) {
|
||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||
// Test local mailbox naming policy.
|
||||
localInvalidTable := []struct {
|
||||
input, msg string
|
||||
@@ -282,6 +318,28 @@ func TestExtractMailboxInvalid(t *testing.T) {
|
||||
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
||||
}
|
||||
}
|
||||
// Test domain mailbox naming policy.
|
||||
domainInvalidTable := []struct {
|
||||
input, msg string
|
||||
}{
|
||||
{"", "Empty mailbox name is not permitted"},
|
||||
{"user@host@domain.com", "@ symbol not permitted"},
|
||||
{"first.last@dom ain.com", "Space not permitted"},
|
||||
{"first\"last@domain.com", "Double quote not permitted"},
|
||||
{"first\nlast@domain.com", "Control chars not permitted"},
|
||||
{"first.last@chars!#$%.com", "Invalid domain name"},
|
||||
{"first.last@.example.com", "Domain cannot start with dot"},
|
||||
{"first.last@-example.com", "Domain canont start with dash"},
|
||||
{"first.last@example.com-", "Domain cannot end with dash"},
|
||||
{"first.last@example..com", "Domain cannot contain double dots"},
|
||||
{"first.last@example--com", "Domain cannot contain double dashes"},
|
||||
{"first.last@example.-com", "Domain cannot contain concecutive symbols"},
|
||||
}
|
||||
for _, tt := range domainInvalidTable {
|
||||
if _, err := domainPolicy.ExtractMailbox(tt.input); err == nil {
|
||||
t.Errorf("Didn't get an error while parsing in domain mode %q: %v", tt.input, tt.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomain(t *testing.T) {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
// MailboxListV1 renders a list of messages in a mailbox
|
||||
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||
for i, msg := range messages {
|
||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
}
|
||||
}
|
||||
return web.RenderJSON(w, jmessages)
|
||||
@@ -64,28 +65,30 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||
for i, part := range attachParts {
|
||||
content := part.Content
|
||||
var checksum = md5.Sum(content)
|
||||
// Example URL: http://localhost/serve/mailbox/swaks/0001/attach/0/favicon.png
|
||||
link := "http://" + req.Host + "/serve/mailbox/" + name + "/" + id + "/attach/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName
|
||||
checksum := md5.Sum(content)
|
||||
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||
ContentType: part.ContentType,
|
||||
FileName: part.FileName,
|
||||
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName,
|
||||
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName,
|
||||
MD5: hex.EncodeToString(checksum[:]),
|
||||
ContentType: part.ContentType,
|
||||
FileName: part.FileName,
|
||||
DownloadLink: link,
|
||||
ViewLink: link,
|
||||
MD5: hex.EncodeToString(checksum[:]),
|
||||
}
|
||||
}
|
||||
return web.RenderJSON(w,
|
||||
&model.JSONMessageV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Body: &model.JSONMessageBodyV1{
|
||||
Text: msg.Text(),
|
||||
HTML: msg.HTML(),
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||
@@ -120,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
|
||||
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||
|
||||
@@ -194,6 +196,10 @@ func TestRestMessage(t *testing.T) {
|
||||
"From": []string{"noreply@inbucket.org"},
|
||||
},
|
||||
},
|
||||
Attachments: []*enmime.Part{{
|
||||
FileName: "favicon.png",
|
||||
ContentType: "image/png",
|
||||
}},
|
||||
},
|
||||
)
|
||||
mm.AddMessage("good", msg1)
|
||||
@@ -221,6 +227,7 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "subject", "subject 1")
|
||||
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "size", 0)
|
||||
decodedBoolEquals(t, result, "seen", true)
|
||||
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||
@@ -228,6 +235,10 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
||||
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
)
|
||||
|
||||
// Client accesses the Inbucket REST API v1
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
func TestClientV1ListMailbox(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
// Example demonstrates basic usage for the Inbucket REST client.
|
||||
|
||||
@@ -33,7 +33,7 @@ func (c *restClient) do(method, uri string, body []byte) (*http.Response, error)
|
||||
}
|
||||
req, err := http.NewRequest(method, url.String(), r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||
}
|
||||
return c.client.Do(req)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
||||
@@ -77,5 +77,5 @@ func (c *restClient) doJSONBody(method string, uri string, body []byte, v interf
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||
type JSONMessageHeaderV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
|
||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Body *JSONMessageBodyV1 `json:"body"`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package rest
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
import "github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||
|
||||
// SetupRoutes populates the routes for the REST interface
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
return
|
||||
}
|
||||
header := &model.JSONMessageHeaderV1{
|
||||
Mailbox: msg.Mailbox,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Mailbox: msg.Mailbox,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
}
|
||||
if conn.WriteJSON(header) != nil {
|
||||
// Write failed
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
},
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
|
||||
return buf
|
||||
}
|
||||
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
|
||||
t.Errorf("JSON result%s", msg)
|
||||
return
|
||||
}
|
||||
if got, ok := val.(float64); ok {
|
||||
got, ok := val.(float64)
|
||||
if ok {
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
|
||||
path, val, val, int64(got), want, int64(want))
|
||||
}
|
||||
|
||||
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// not an EOF
|
||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -25,10 +25,27 @@ const (
|
||||
// timeStampFormat to use in Received header.
|
||||
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||
|
||||
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
|
||||
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
|
||||
|
||||
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
|
||||
// `User Name`
|
||||
usernameChallenge = "VXNlciBOYW1lAA=="
|
||||
|
||||
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
|
||||
// `Password`
|
||||
passwordChallenge = "UGFzc3dvcmQA"
|
||||
)
|
||||
|
||||
const (
|
||||
// GREET State: Waiting for HELO
|
||||
GREET State = iota
|
||||
// READY State: Got HELO, waiting for MAIL
|
||||
READY
|
||||
// LOGIN State: Got AUTH LOGIN command, expecting Username
|
||||
LOGIN
|
||||
// PASSWORD State: Got Username, expecting password
|
||||
PASSWORD
|
||||
// MAIL State: Got MAIL, accepting RCPTs
|
||||
MAIL
|
||||
// DATA State: Got DATA, waiting for "."
|
||||
@@ -37,11 +54,11 @@ const (
|
||||
QUIT
|
||||
)
|
||||
|
||||
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
|
||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||
// (?:) is non-grouping sub-match
|
||||
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
|
||||
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
|
||||
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
|
||||
var fromRegex = regexp.MustCompile(
|
||||
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
||||
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( [\w= ]+(?:=<>)?)?$`)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
@@ -76,6 +93,7 @@ var commands = map[string]bool{
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"STARTTLS": true,
|
||||
"AUTH": true,
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
@@ -153,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
line, err := ssn.readLine()
|
||||
if err == nil {
|
||||
//Handle LOGIN/PASSWORD states here, because they don't expect a command
|
||||
switch ssn.state {
|
||||
case LOGIN:
|
||||
ssn.loginHandler(line)
|
||||
continue
|
||||
case PASSWORD:
|
||||
ssn.passwordHandler(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||
// Check against valid SMTP commands
|
||||
if cmd == "" {
|
||||
@@ -219,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
break
|
||||
}
|
||||
// not an EOF
|
||||
// Not an EOF
|
||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
@@ -257,9 +285,10 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
s.remoteDomain = domain
|
||||
// features before SIZE per RFC
|
||||
// Features before SIZE per RFC
|
||||
s.send("250-" + readyBanner)
|
||||
s.send("250-8BITMIME")
|
||||
s.send("250-AUTH PLAIN LOGIN")
|
||||
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||
s.send("250-STARTTLS")
|
||||
}
|
||||
@@ -281,30 +310,71 @@ func parseHelloArgument(arg string) (string, error) {
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func (s *Session) loginHandler(line string) {
|
||||
// Content and length of username is ignored.
|
||||
s.send(fmt.Sprintf("334 %v", passwordChallenge))
|
||||
s.enterState(PASSWORD)
|
||||
}
|
||||
|
||||
func (s *Session) passwordHandler(line string) {
|
||||
// Content and length of password is ignored.
|
||||
s.send("235 Authentication successful")
|
||||
s.enterState(READY)
|
||||
}
|
||||
|
||||
// READY state -> waiting for MAIL
|
||||
// AUTH can change
|
||||
func (s *Session) readyHandler(cmd string, arg string) {
|
||||
if cmd == "STARTTLS" {
|
||||
if !s.Server.config.TLSEnabled {
|
||||
// invalid command since unconfigured
|
||||
// Invalid command since TLS unconfigured.
|
||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||
s.send("454 TLS unavailable on the server")
|
||||
return
|
||||
}
|
||||
if s.tlsState != nil {
|
||||
// tls state previously valid
|
||||
// TLS state previously valid.
|
||||
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||
s.send("454 A TLS session already agreed upon.")
|
||||
return
|
||||
}
|
||||
s.logger.Debug().Msg("Initiating TLS context.")
|
||||
|
||||
// Start TLS connection handshake.
|
||||
s.send("220 STARTTLS")
|
||||
// start tls connection handshake
|
||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||
s.conn = tlsConn
|
||||
s.text = textproto.NewConn(s.conn)
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
s.enterState(GREET)
|
||||
} else if cmd == "AUTH" {
|
||||
args := strings.SplitN(arg, " ", 3)
|
||||
authMethod := args[0]
|
||||
switch authMethod {
|
||||
case "PLAIN":
|
||||
{
|
||||
if len(args) != 2 {
|
||||
s.send("500 Bad auth arguments")
|
||||
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
|
||||
return
|
||||
}
|
||||
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
|
||||
s.send("235 2.7.0 Authentication successful")
|
||||
return
|
||||
}
|
||||
case "LOGIN":
|
||||
{
|
||||
s.send(fmt.Sprintf("334 %v", usernameChallenge))
|
||||
s.enterState(LOGIN)
|
||||
return
|
||||
}
|
||||
default:
|
||||
{
|
||||
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if cmd == "MAIL" {
|
||||
// Capture group 1: from address. 2: optional params.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
@@ -314,11 +384,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := policy.ParseEmailAddress(from); err != nil {
|
||||
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||
s.send("501 Bad sender address syntax")
|
||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
}
|
||||
if from == "" {
|
||||
from = "unspecified"
|
||||
}
|
||||
|
||||
// This is where the client may put BODY=8BITMIME, but we already
|
||||
// read the DATA as bytes, so it does not effect our processing.
|
||||
if m[2] != "" {
|
||||
@@ -346,6 +420,11 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
s.logger.Info().Msgf("Mail from: %v", from)
|
||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||
s.enterState(MAIL)
|
||||
} else if cmd == "EHLO" {
|
||||
// Reset session
|
||||
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||
s.reset()
|
||||
s.send("250 Session reset")
|
||||
} else {
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
@@ -394,6 +473,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
||||
}
|
||||
s.enterState(DATA)
|
||||
return
|
||||
case "EHLO":
|
||||
// Reset session
|
||||
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||
s.reset()
|
||||
s.send("250 Session reset")
|
||||
return
|
||||
}
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
@@ -422,6 +507,7 @@ func (s *Session) dataHandler() {
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||
@@ -502,28 +588,28 @@ func (s *Session) readLine() (line string, err error) {
|
||||
|
||||
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
l := len(line)
|
||||
|
||||
// Find length of command or entire line.
|
||||
hasArg := true
|
||||
l := strings.IndexByte(line, ' ')
|
||||
if l == -1 {
|
||||
hasArg = false
|
||||
l = len(line)
|
||||
}
|
||||
|
||||
switch {
|
||||
case l == 0:
|
||||
return "", "", true
|
||||
case l < 4:
|
||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||
return "", "", false
|
||||
case l == 4 || l == 8:
|
||||
return strings.ToUpper(line), "", true
|
||||
case l == 5:
|
||||
// Too long to be only command, too short to have args
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
}
|
||||
// If we made it here, command is long enough to have args
|
||||
if line[4] != ' ' {
|
||||
// There wasn't a space after the command?
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
|
||||
if hasArg {
|
||||
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
|
||||
}
|
||||
// I'm not sure if we should trim the args or not, but we will for now
|
||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||
|
||||
return strings.ToUpper(line), "", true
|
||||
}
|
||||
|
||||
// parseArgs takes the arguments proceeding a command and files them
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -56,6 +56,9 @@ func TestGreetState(t *testing.T) {
|
||||
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Valid EHLOs
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
||||
@@ -70,6 +73,82 @@ func TestGreetState(t *testing.T) {
|
||||
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestEmptyEnvelope(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some empty envelope without blanks
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test out some empty envelope with blanks
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM: <>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test AUTH
|
||||
func TestAuth(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
|
||||
// PLAIN AUTH
|
||||
script := []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"AUTH PLAIN aW5idWNrZXQ6cGFzc3dvcmQK", 235},
|
||||
{"RSET", 250},
|
||||
{"AUTH GSSAPI aW5idWNrZXQ6cGFzc3dvcmQK", 500},
|
||||
{"RSET", 250},
|
||||
{"AUTH PLAIN", 500},
|
||||
{"RSET", 250},
|
||||
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// LOGIN AUTH
|
||||
script = []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"AUTH LOGIN", 334}, // Test with user/pass present.
|
||||
{"username", 334},
|
||||
{"password", 235},
|
||||
{"RSET", 250},
|
||||
{"AUTH LOGIN", 334}, // Test with empty user/pass.
|
||||
{"", 334},
|
||||
{"", 235},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -114,6 +193,10 @@ func TestReadyState(t *testing.T) {
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<\"first last\"@space.com>", 250},
|
||||
@@ -130,6 +213,15 @@ func TestReadyState(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test Start TLS parsing.
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"STARTTLS", 454}, // TLS unconfigured.
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
@@ -206,6 +298,19 @@ func TestMailState(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test late EHLO, similar to RSET
|
||||
script = []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"EHLO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
{"RCPT TO:<u1@gmail.com>", 250},
|
||||
{"EHLO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test RSET
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
6
pkg/server/web/app_json.go
Normal file
6
pkg/server/web/app_json.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
type jsonAppConfig struct {
|
||||
BasePath string `json:"base-path"`
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
}
|
||||
@@ -5,17 +5,15 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
)
|
||||
|
||||
// Context is passed into every request handler function
|
||||
// TODO remove redundant web config
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
Session *sessions.Session
|
||||
MsgHub *msghub.Hub
|
||||
Manager message.Manager
|
||||
RootConfig *config.Root
|
||||
@@ -48,24 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
|
||||
// NewContext returns a Context for the given HTTP Request
|
||||
func NewContext(req *http.Request) (*Context, error) {
|
||||
vars := mux.Vars(req)
|
||||
sess, err := sessionStore.Get(req, "inbucket")
|
||||
if err != nil {
|
||||
if sess == nil {
|
||||
// No session, must fail
|
||||
return nil, err
|
||||
}
|
||||
// The session cookie was probably signed by an old key, ignore it
|
||||
// gorilla created an empty session for us
|
||||
err = nil
|
||||
}
|
||||
ctx := &Context{
|
||||
Vars: vars,
|
||||
Session: sess,
|
||||
MsgHub: msgHub,
|
||||
Manager: manager,
|
||||
RootConfig: rootConfig,
|
||||
WebConfig: rootConfig.Web,
|
||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
return ctx, err
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
106
pkg/server/web/handlers.go
Normal file
106
pkg/server/web/handlers.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket.
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler.
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context.
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it.
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error handling request")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cookieHandler injects an HTTP cookie into the response.
|
||||
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
|
||||
http.SetCookie(w, cookie)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// fileHandler creates a handler that sends the named file regardless of the requested URL.
|
||||
func fileHandler(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||
Msg("Error opening file")
|
||||
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
d, err := f.Stat()
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||
Msg("Error stating file")
|
||||
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
|
||||
})
|
||||
}
|
||||
|
||||
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
|
||||
// returning specified statusCode to the client.
|
||||
func noMatchHandler(statusCode int, message string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
|
||||
w.WriteHeader(statusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// requestLoggingWrapper returns middleware that logs client requests.
|
||||
func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
|
||||
func spaTemplateHandler(tmpl *template.Template, basePath string,
|
||||
webConfig config.Web) http.Handler {
|
||||
tmplData := struct {
|
||||
BasePath string
|
||||
}{
|
||||
BasePath: basePath,
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// ensure we do now allow click jacking
|
||||
w.Header().Set("X-Frame-Options", "SameOrigin")
|
||||
err := tmpl.Execute(w, tmplData)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error rendering SPA index template")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,62 +3,20 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"address": stringutil.StringAddress,
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"stringsJoin": strings.Join,
|
||||
"textToHtml": TextToHTML,
|
||||
}
|
||||
|
||||
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||
|
||||
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
|
||||
// otherwise Mon Jan 2, 2006
|
||||
func FriendlyTime(t time.Time) template.HTML {
|
||||
ty, tm, td := t.Date()
|
||||
ny, nm, nd := time.Now().Date()
|
||||
if (ty == ny) && (tm == nm) && (td == nd) {
|
||||
return template.HTML(t.Format("03:04:05 PM"))
|
||||
}
|
||||
return template.HTML(t.Format("Mon Jan 2, 2006"))
|
||||
}
|
||||
|
||||
// Reverse routing function (shared with templates)
|
||||
func Reverse(name string, things ...interface{}) string {
|
||||
// Convert the things to strings
|
||||
strs := make([]string, len(things))
|
||||
for i, th := range things {
|
||||
strs[i] = fmt.Sprint(th)
|
||||
}
|
||||
// Grab the route
|
||||
u, err := Router.Get(name).URL(strs...)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("name", name).Err(err).
|
||||
Msg("Failed to reverse route")
|
||||
return "/ROUTE-ERROR"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func TextToHTML(text string) template.HTML {
|
||||
func TextToHTML(text string) string {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(replacer.Replace(text))
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextToHtml(t *testing.T) {
|
||||
// Identity
|
||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
||||
|
||||
// Check it escapes
|
||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
||||
|
||||
// Check for linebreaks
|
||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
}
|
||||
|
||||
func TestURLDetection(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://google.com/"),
|
||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://a.com/?q=a&n=v"),
|
||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
||||
testCases := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{
|
||||
input: "html",
|
||||
want: "html",
|
||||
},
|
||||
// Check it escapes.
|
||||
{
|
||||
input: "<html>",
|
||||
want: "<html>",
|
||||
},
|
||||
// Check for linebreaks.
|
||||
{
|
||||
input: "line\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\r\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\rbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
// Check URL detection.
|
||||
{
|
||||
input: "http://google.com/",
|
||||
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||
},
|
||||
{
|
||||
input: "http://a.com/?q=a&n=v",
|
||||
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||
},
|
||||
{
|
||||
input: "(http://a.com/?q=a&n=v)",
|
||||
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := TextToHTML(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,25 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
const (
|
||||
staticDir = "static"
|
||||
templateDir = "templates"
|
||||
)
|
||||
|
||||
var (
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
@@ -39,7 +34,6 @@ var (
|
||||
rootConfig *config.Root
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
globalShutdown chan bool
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
@@ -51,7 +45,7 @@ func init() {
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method
|
||||
// Initialize sets up things for unit tests or the Start() method.
|
||||
func Initialize(
|
||||
conf *config.Root,
|
||||
shutdownChan chan bool,
|
||||
@@ -61,44 +55,76 @@ func Initialize(
|
||||
rootConfig = conf
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
// NewContext() will use this DataStore for the web handlers.
|
||||
msgHub = mh
|
||||
manager = mm
|
||||
|
||||
// Content Paths
|
||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(staticPath))))
|
||||
Router.Handle("/debug/vars", expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
|
||||
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Go pprof tools installed to /debug/pprof")
|
||||
// Redirect requests to / if there is a base path configured.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
redirectBase := prefix("/")
|
||||
if redirectBase != "/" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||
Msg("Base path configured")
|
||||
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||
}
|
||||
|
||||
// Session cookie setup
|
||||
if conf.Web.CookieAuthKey == "" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
||||
// Dynamic paths.
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||
}
|
||||
|
||||
// Static paths.
|
||||
Router.PathPrefix(prefix("/static")).Handler(
|
||||
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||
Router.Path(prefix("/favicon.png")).Handler(
|
||||
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||
|
||||
// Parse index.html template, allowing for configuration to be passed to the SPA.
|
||||
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
|
||||
indexTmpl, err := template.ParseFiles(indexPath)
|
||||
if err != nil {
|
||||
msg := "Failed to parse HTML template"
|
||||
cwd, _ := os.Getwd()
|
||||
log.Error().
|
||||
Str("module", "web").
|
||||
Str("phase", "startup").
|
||||
Str("path", indexPath).
|
||||
Str("cwd", cwd).
|
||||
Err(err).
|
||||
Msg(msg)
|
||||
// Create a dummy template to allow tests to pass.
|
||||
indexTmpl, _ = template.New("index.html").Parse(msg)
|
||||
}
|
||||
|
||||
// SPA managed paths.
|
||||
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||
Router.Path(prefix("/")).Handler(spaHandler)
|
||||
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||
|
||||
// Error handlers.
|
||||
Router.NotFoundHandler = noMatchHandler(
|
||||
http.StatusNotFound, "No route matches URI path")
|
||||
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
server = &http.Server{
|
||||
Addr: rootConfig.Web.Addr,
|
||||
Handler: Router,
|
||||
Handler: requestLoggingWrapper(Router),
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
@@ -132,6 +158,23 @@ func Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||
o := &jsonAppConfig{
|
||||
BasePath: webConfig.BasePath,
|
||||
MonitorVisible: webConfig.MonitorVisible,
|
||||
}
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||
Msg("Failed to convert app-config to JSON")
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: "app-config",
|
||||
Value: url.PathEscape(string(b)),
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
@@ -148,29 +191,6 @@ func serve(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error handling request")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var cachedMutex sync.Mutex
|
||||
var cachedTemplates = map[string]*template.Template{}
|
||||
var cachedPartials = map[string]*template.Template{}
|
||||
|
||||
// RenderTemplate fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, false)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||
Msg("Error in template")
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// RenderPartial fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, true)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||
Msg("Error in template")
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// ParseTemplate loads the requested template along with _base.html, caching
|
||||
// the result (if configured to do so)
|
||||
func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
||||
cachedMutex.Lock()
|
||||
defer cachedMutex.Unlock()
|
||||
|
||||
if t, ok := cachedTemplates[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
|
||||
|
||||
var err error
|
||||
var t *template.Template
|
||||
if partial {
|
||||
// Need to get basename of file to make it root template w/ funcs
|
||||
base := path.Base(name)
|
||||
t = template.New(base).Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(tempFile)
|
||||
} else {
|
||||
t = template.New("_base.html").Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(
|
||||
filepath.Join(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Allows us to disable caching for theme development
|
||||
if rootConfig.Web.TemplateCache {
|
||||
if partial {
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
|
||||
cachedTemplates[name] = t
|
||||
} else {
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
|
||||
cachedTemplates[name] = t
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
func TestHashLock(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Message is a memory store message.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Store implements an in-memory message store.
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
// TestSuite runs storage package test suite on file store.
|
||||
@@ -65,7 +65,7 @@ func TestMaxSize(t *testing.T) {
|
||||
go func(mailbox string) {
|
||||
err := s.PurgeMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
panic(err) // Cannot call t.Fatal from non-test goroutine.
|
||||
}
|
||||
wg.Done()
|
||||
}(mailbox)
|
||||
|
||||
@@ -3,17 +3,15 @@ package storage
|
||||
import (
|
||||
"container/list"
|
||||
"expvar"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
retentionScanCompleted = time.Now()
|
||||
retentionScanCompletedMu sync.RWMutex
|
||||
scanCompletedMillis = new(expvar.Int)
|
||||
|
||||
// History counters
|
||||
expRetentionDeletesTotal = new(expvar.Int)
|
||||
@@ -34,7 +32,7 @@ var (
|
||||
|
||||
func init() {
|
||||
rm := expvar.NewMap("retention")
|
||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
||||
rm.Set("ScanCompletedMillis", scanCompletedMillis)
|
||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||
rm.Set("Period", expRetentionPeriod)
|
||||
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
|
||||
return err
|
||||
}
|
||||
// Update metrics
|
||||
setRetentionScanCompleted(time.Now())
|
||||
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
expRetainedSize.Set(storeSize)
|
||||
return nil
|
||||
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
|
||||
<-rs.retentionShutdown
|
||||
}
|
||||
}
|
||||
|
||||
func setRetentionScanCompleted(t time.Time) {
|
||||
retentionScanCompletedMu.Lock()
|
||||
defer retentionScanCompletedMu.Unlock()
|
||||
retentionScanCompleted = t
|
||||
}
|
||||
|
||||
func getRetentionScanCompleted() time.Time {
|
||||
retentionScanCompletedMu.RLock()
|
||||
defer retentionScanCompletedMu.RUnlock()
|
||||
return retentionScanCompleted
|
||||
}
|
||||
|
||||
func secondsSinceRetentionScanCompleted() interface{} {
|
||||
return time.Since(getRetentionScanCompleted()) / time.Second
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
func TestDoRetentionScan(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
package stringutil_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
func TestHashMailboxName(t *testing.T) {
|
||||
@@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakePathPrefixer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
prefix, path, want string
|
||||
}{
|
||||
{prefix: "", path: "", want: ""},
|
||||
{prefix: "", path: "relative", want: "relative"},
|
||||
{prefix: "", path: "/qualified", want: "/qualified"},
|
||||
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
|
||||
{prefix: "pfx", path: "", want: "/pfx"},
|
||||
{prefix: "pfx", path: "/", want: "/pfx/"},
|
||||
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "/pfx/", path: "", want: "/pfx"},
|
||||
{prefix: "/pfx/", path: "/", want: "/pfx/"},
|
||||
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "a/b/c", path: "", want: "/a/b/c"},
|
||||
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
|
||||
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
|
||||
prefixer := stringutil.MakePathPrefixer(tc.prefix)
|
||||
got := prefixer(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/jhillyerd/goldiff"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,8 +37,7 @@ func TestSuite(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = stopServer
|
||||
// defer stopServer()
|
||||
defer stopServer()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -152,6 +153,7 @@ func formatMessage(m *client.Message) []byte {
|
||||
|
||||
func startServer() (func(), error) {
|
||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||
storage.Constructors["memory"] = mem.New
|
||||
os.Clearenv()
|
||||
conf, err := config.Process()
|
||||
@@ -169,9 +171,9 @@ func startServer() (func(), error) {
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
// Start HTTP server.
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
|
||||
@@ -3,10 +3,10 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// ManagerStub is a test stub for message.Manager
|
||||
|
||||
@@ -3,7 +3,7 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// StoreStub stubs storage.Store for testing.
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// StoreFactory returns a new store for the test suite.
|
||||
|
||||
@@ -1,144 +1,82 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// MailboxIndex renders the index page for a particular mailbox
|
||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Form values must be validated manually
|
||||
name := req.FormValue("name")
|
||||
selected := req.FormValue("id")
|
||||
if len(name) == 0 {
|
||||
ctx.Session.AddFlash("Account name is required", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
name, err = ctx.Manager.MailboxForAddress(name)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Remember this mailbox was visited
|
||||
RememberMailbox(ctx, name)
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"name": name,
|
||||
"selected": selected,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
|
||||
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxLink handles pretty links to a particular message. Renders a redirect
|
||||
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxList renders a list of messages in a mailbox. Renders a partial
|
||||
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages, err := ctx.Manager.GetMetadata(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"messages": messages,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
|
||||
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
if err != nil && err != storage.ErrNotExist {
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if msg == nil {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
|
||||
attachments := make([]*jsonAttachment, 0)
|
||||
for i, part := range msg.Attachments() {
|
||||
attachments = append(attachments, &jsonAttachment{
|
||||
ID: strconv.Itoa(i),
|
||||
FileName: part.FileName,
|
||||
ContentType: part.ContentType,
|
||||
})
|
||||
}
|
||||
body := template.HTML(web.TextToHTML(msg.Text()))
|
||||
htmlAvailable := msg.HTML() != ""
|
||||
var htmlBody template.HTML
|
||||
if htmlAvailable {
|
||||
|
||||
mimeErrors := make([]*jsonMIMEError, 0)
|
||||
for _, e := range msg.MIMEErrors() {
|
||||
mimeErrors = append(mimeErrors, &jsonMIMEError{
|
||||
Name: e.Name,
|
||||
Detail: e.Detail,
|
||||
Severe: e.Severe,
|
||||
})
|
||||
}
|
||||
|
||||
// Sanitize HTML body.
|
||||
htmlBody := ""
|
||||
if msg.HTML() != "" {
|
||||
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||
htmlBody = template.HTML(str)
|
||||
htmlBody = str
|
||||
} else {
|
||||
// Soft failure, render empty tab.
|
||||
htmlBody = "Inbucket HTML sanitizer failed."
|
||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||
Msg("HTML sanitizer failed")
|
||||
}
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"message": msg,
|
||||
"body": body,
|
||||
"htmlAvailable": htmlAvailable,
|
||||
"htmlBody": htmlBody,
|
||||
"mimeErrors": msg.MIMEErrors(),
|
||||
"attachments": msg.Attachments(),
|
||||
})
|
||||
|
||||
return web.RenderJSON(w,
|
||||
&jsonMessage{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Text: web.TextToHTML(msg.Text()),
|
||||
HTML: htmlBody,
|
||||
Attachments: attachments,
|
||||
Errors: mimeErrors,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxHTML displays the HTML content of a message. Renders a partial
|
||||
@@ -158,14 +96,10 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (er
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
// Render partial template
|
||||
// Render HTML
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"message": msg,
|
||||
"body": template.HTML(msg.HTML()),
|
||||
})
|
||||
_, err = w.Write([]byte(msg.HTML()))
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
|
||||
@@ -191,66 +125,18 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxDownloadAttach sends the attachment to the client; disposition:
|
||||
// attachment, type: application/octet-stream
|
||||
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
numStr := ctx.Vars["num"]
|
||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if int(num) >= len(msg.Attachments()) {
|
||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Output attachment
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
_, err = w.Write(msg.Attachments()[num].Content)
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
id := ctx.Vars["id"]
|
||||
numStr := ctx.Vars["num"]
|
||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
@@ -262,10 +148,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Contex
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if int(num) >= len(msg.Attachments()) {
|
||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return errors.New("requested attachment number does not exist")
|
||||
}
|
||||
// Output attachment
|
||||
part := msg.Attachments()[num]
|
||||
|
||||
34
pkg/webui/mailbox_json.go
Normal file
34
pkg/webui/mailbox_json.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package webui
|
||||
|
||||
import "time"
|
||||
|
||||
// jsonMessage formats message data for the UI.
|
||||
type jsonMessage struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Header map[string][]string `json:"header"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
Attachments []*jsonAttachment `json:"attachments"`
|
||||
Errors []*jsonMIMEError `json:"errors"`
|
||||
}
|
||||
|
||||
// jsonAttachment formats attachment data for the UI.
|
||||
type jsonAttachment struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"filename"`
|
||||
ContentType string `json:"content-type"`
|
||||
}
|
||||
|
||||
type jsonMIMEError struct {
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail"`
|
||||
Severe bool `json:"severe"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
const (
|
||||
// maximum mailboxes to remember
|
||||
maxRemembered = 8
|
||||
// session value key; referenced in templates, do not change
|
||||
mailboxKey = "recentMailboxes"
|
||||
)
|
||||
|
||||
// RememberMailbox manages the list of recently accessed mailboxes stored in the session
|
||||
func RememberMailbox(ctx *web.Context, mailbox string) {
|
||||
recent := RecentMailboxes(ctx)
|
||||
newRecent := make([]string, 1, maxRemembered)
|
||||
newRecent[0] = mailbox
|
||||
|
||||
for _, recBox := range recent {
|
||||
// Insert until newRecent is full, but don't repeat the new mailbox
|
||||
if len(newRecent) < maxRemembered && mailbox != recBox {
|
||||
newRecent = append(newRecent, recBox)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Session.Values[mailboxKey] = newRecent
|
||||
}
|
||||
|
||||
// RecentMailboxes returns a slice of the most recently accessed mailboxes
|
||||
func RecentMailboxes(ctx *web.Context) []string {
|
||||
val := ctx.Session.Values[mailboxKey]
|
||||
recent, _ := val.([]string)
|
||||
return recent
|
||||
}
|
||||
@@ -2,98 +2,52 @@ package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
// RootIndex serves the Inbucket landing page
|
||||
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// RootGreeting serves the Inbucket greeting.
|
||||
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to load greeting: %v", err)
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"greeting": template.HTML(string(greeting)),
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, err = w.Write(greeting)
|
||||
return err
|
||||
}
|
||||
|
||||
// RootMonitor serves the Inbucket monitor page
|
||||
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
if !ctx.RootConfig.Web.MonitorVisible {
|
||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
})
|
||||
}
|
||||
|
||||
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
|
||||
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
if !ctx.RootConfig.Web.MonitorVisible {
|
||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// RootStatus serves the Inbucket status page
|
||||
// RootStatus renders portions of the server configuration as JSON.
|
||||
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
root := ctx.RootConfig
|
||||
retPeriod := ""
|
||||
if root.Storage.RetentionPeriod > 0 {
|
||||
retPeriod = root.Storage.RetentionPeriod.String()
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"version": config.Version,
|
||||
"buildDate": config.BuildDate,
|
||||
"smtpListener": ctx.RootConfig.SMTP.Addr,
|
||||
"pop3Listener": ctx.RootConfig.POP3.Addr,
|
||||
"webListener": ctx.RootConfig.Web.Addr,
|
||||
"smtpConfig": ctx.RootConfig.SMTP,
|
||||
"storageConfig": ctx.RootConfig.Storage,
|
||||
})
|
||||
|
||||
return web.RenderJSON(w,
|
||||
&jsonServerConfig{
|
||||
Version: config.Version,
|
||||
BuildDate: config.BuildDate,
|
||||
POP3Listener: root.POP3.Addr,
|
||||
WebListener: root.Web.Addr,
|
||||
SMTPConfig: jsonSMTPConfig{
|
||||
Addr: root.SMTP.Addr,
|
||||
DefaultAccept: root.SMTP.DefaultAccept,
|
||||
AcceptDomains: root.SMTP.AcceptDomains,
|
||||
RejectDomains: root.SMTP.RejectDomains,
|
||||
DefaultStore: root.SMTP.DefaultStore,
|
||||
StoreDomains: root.SMTP.StoreDomains,
|
||||
DiscardDomains: root.SMTP.DiscardDomains,
|
||||
},
|
||||
StorageConfig: jsonStorageConfig{
|
||||
MailboxMsgCap: root.Storage.MailboxMsgCap,
|
||||
StoreType: root.Storage.Type,
|
||||
RetentionPeriod: retPeriod,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,35 +3,21 @@ package webui
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
// SetupRoutes populates routes for the webui into the provided Router
|
||||
// SetupRoutes populates routes for the webui into the provided Router.
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
r.Path("/").Handler(
|
||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
||||
r.Path("/monitor").Handler(
|
||||
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
|
||||
r.Path("/monitor/{name}").Handler(
|
||||
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
||||
r.Path("/greeting").Handler(
|
||||
web.Handler(RootGreeting)).Name("RootGreeting").Methods("GET")
|
||||
r.Path("/status").Handler(
|
||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
||||
r.Path("/link/{name}/{id}").Handler(
|
||||
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
|
||||
r.Path("/mailbox").Handler(
|
||||
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
|
||||
r.Path("/mailbox/{name}").Handler(
|
||||
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
|
||||
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}/html").Handler(
|
||||
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
|
||||
web.Handler(MailboxHTML)).Name("MailboxHTML").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}/source").Handler(
|
||||
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
||||
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
|
||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
||||
r.Path("/{name}").Handler(
|
||||
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package sanitize_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||
)
|
||||
|
||||
// TestHTMLPlainStrings test plain text passthrough
|
||||
|
||||
26
pkg/webui/status_json.go
Normal file
26
pkg/webui/status_json.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package webui
|
||||
|
||||
type jsonServerConfig struct {
|
||||
Version string `json:"version"`
|
||||
BuildDate string `json:"build-date"`
|
||||
POP3Listener string `json:"pop3-listener"`
|
||||
WebListener string `json:"web-listener"`
|
||||
SMTPConfig jsonSMTPConfig `json:"smtp-config"`
|
||||
StorageConfig jsonStorageConfig `json:"storage-config"`
|
||||
}
|
||||
|
||||
type jsonSMTPConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
DefaultAccept bool `json:"default-accept"`
|
||||
AcceptDomains []string `json:"accept-domains"`
|
||||
RejectDomains []string `json:"reject-domains"`
|
||||
DefaultStore bool `json:"default-store"`
|
||||
StoreDomains []string `json:"store-domains"`
|
||||
DiscardDomains []string `json:"discard-domains"`
|
||||
}
|
||||
|
||||
type jsonStorageConfig struct {
|
||||
MailboxMsgCap int `json:"mailbox-msg-cap"`
|
||||
StoreType string `json:"store-type"`
|
||||
RetentionPeriod string `json:"retention-period"`
|
||||
}
|
||||
21
shell.nix
Normal file
21
shell.nix
Normal file
@@ -0,0 +1,21 @@
|
||||
with import <nixpkgs> {};
|
||||
stdenv.mkDerivation rec {
|
||||
name = "env";
|
||||
env = buildEnv { name = name; paths = buildInputs; };
|
||||
buildInputs = [
|
||||
act
|
||||
dpkg
|
||||
elmPackages.elm
|
||||
elmPackages.elm-analyse
|
||||
elmPackages.elm-format
|
||||
elmPackages.elm-json
|
||||
elmPackages.elm-language-server
|
||||
elmPackages.elm-test
|
||||
go
|
||||
golint
|
||||
nodejs-16_x
|
||||
nodePackages.yarn
|
||||
rpm
|
||||
swaks
|
||||
];
|
||||
}
|
||||
4
ui/.parcelrc
Normal file
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"
|
||||
}
|
||||
}
|
||||
44
ui/README.md
Normal file
44
ui/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Inbucket User Interface
|
||||
|
||||
This directory contains the source code for the Inbucket web user interface.
|
||||
It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
|
||||
|
||||
## Development
|
||||
|
||||
With `$INBUCKET` as the root of the git repository.
|
||||
|
||||
One time setup (assuming [Node.js] is already installed):
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
||||
|
||||
### Terminal 1: inbucket daemon
|
||||
|
||||
```
|
||||
cd $INBUCKET
|
||||
make
|
||||
etc/dev-start.sh
|
||||
```
|
||||
|
||||
Inbucket will start, with HTTP listening on port 9000. You may verify the web
|
||||
UI is functional if this is your first time building Inbucket, but your dev/test
|
||||
cycle should favor the development server below.
|
||||
|
||||
### Terminal 2: parcel development server
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
yarn start
|
||||
```
|
||||
|
||||
yarn will start a development HTTP server listening on port 1234. You should
|
||||
use this server for UI development, as it features hot reload and the Elm
|
||||
debugger.
|
||||
|
||||
[Elm]: https://elm-lang.org
|
||||
[Node.js]: https://nodejs.org
|
||||
34
ui/elm.json
Normal file
34
ui/elm.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||
"basti1302/elm-human-readable-filesize": "1.2.0",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"jweir/sparkline": "4.0.0",
|
||||
"ryannhg/date-format": "2.3.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"myrho/elm-round": "1.0.4"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
|
||||
32
ui/package.json
Normal file
32
ui/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "inbucket-ui",
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "parcel build --public-url ./",
|
||||
"start": "parcel --hmr-port 1235 src/index-dev.html",
|
||||
"clean": "rm -rf .parcel-cache dist elm-stuff"
|
||||
},
|
||||
"source": "src/index.html",
|
||||
"parcel-namer-rewrite": {
|
||||
"rules": {
|
||||
"(.*)\\.(css|js|json|eot|png|svg|ttf|webmanifest|woff|woff2)": "static/$1{.hash}.$2"
|
||||
}
|
||||
},
|
||||
"browserslist": "defaults",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||
"@parcel/packager-raw-url": "2.4.1",
|
||||
"@parcel/transformer-elm": "^2.2.1",
|
||||
"@parcel/transformer-webmanifest": "2.4.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.5.0",
|
||||
"opensans-npm-webfont": "^1.0.0",
|
||||
"parcel": "^2.4.1",
|
||||
"parcel-namer-rewrite": "^2.0.0-rc.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"elm": "^0.19.1-5"
|
||||
}
|
||||
}
|
||||
188
ui/src/Api.elm
Normal file
188
ui/src/Api.elm
Normal file
@@ -0,0 +1,188 @@
|
||||
module Api exposing
|
||||
( DataResult
|
||||
, HttpResult
|
||||
, deleteMessage
|
||||
, getGreeting
|
||||
, getHeaderList
|
||||
, getMessage
|
||||
, getServerConfig
|
||||
, getServerMetrics
|
||||
, markMessageSeen
|
||||
, monitorUri
|
||||
, purgeMailbox
|
||||
, serveUrl
|
||||
)
|
||||
|
||||
import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Metrics as Metrics exposing (Metrics)
|
||||
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
||||
import Data.Session exposing (Session)
|
||||
import Http
|
||||
import HttpUtil
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import String
|
||||
import Url.Builder
|
||||
|
||||
|
||||
type alias DataResult msg data =
|
||||
Result HttpUtil.Error data -> msg
|
||||
|
||||
|
||||
type alias HttpResult msg =
|
||||
Result HttpUtil.Error () -> msg
|
||||
|
||||
|
||||
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||
deleteMessage session msg mailboxName id =
|
||||
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||
|
||||
|
||||
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
|
||||
getHeaderList session msg mailboxName =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = apiV1Url session [ "mailbox", mailboxName ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg (Decode.list MessageHeader.decoder)
|
||||
}
|
||||
|
||||
|
||||
getGreeting : Session -> DataResult msg String -> Cmd msg
|
||||
getGreeting session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl session [ "greeting" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectString context msg
|
||||
}
|
||||
|
||||
|
||||
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
|
||||
getMessage session msg mailboxName id =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl session [ "mailbox", mailboxName, id ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg Message.decoder
|
||||
}
|
||||
|
||||
|
||||
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
|
||||
getServerConfig session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl session [ "status" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg ServerConfig.decoder
|
||||
}
|
||||
|
||||
|
||||
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
|
||||
getServerMetrics session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url =
|
||||
Url.Builder.absolute
|
||||
(splitBasePath session.config.basePath
|
||||
++ [ "debug"
|
||||
, "vars"
|
||||
]
|
||||
)
|
||||
[]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg Metrics.decoder
|
||||
}
|
||||
|
||||
|
||||
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||
markMessageSeen session msg mailboxName id =
|
||||
-- The URL tells the API which message ID to update, so we only need to indicate the
|
||||
-- desired change in the body.
|
||||
Encode.object [ ( "seen", Encode.bool True ) ]
|
||||
|> Http.jsonBody
|
||||
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||
|
||||
|
||||
monitorUri : Session -> String
|
||||
monitorUri session =
|
||||
apiV1Url session [ "monitor", "messages" ]
|
||||
|
||||
|
||||
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
|
||||
purgeMailbox session msg mailboxName =
|
||||
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
|
||||
|
||||
|
||||
{-| Builds a public REST API URL (see wiki).
|
||||
-}
|
||||
apiV1Url : Session -> List String -> String
|
||||
apiV1Url session elements =
|
||||
Url.Builder.absolute
|
||||
(List.concat
|
||||
[ splitBasePath session.config.basePath
|
||||
, [ "api", "v1" ]
|
||||
, elements
|
||||
]
|
||||
)
|
||||
[]
|
||||
|
||||
|
||||
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
||||
-}
|
||||
serveUrl : Session -> List String -> String
|
||||
serveUrl session elements =
|
||||
Url.Builder.absolute
|
||||
(List.concat
|
||||
[ splitBasePath session.config.basePath
|
||||
, [ "serve" ]
|
||||
, elements
|
||||
]
|
||||
)
|
||||
[]
|
||||
|
||||
|
||||
{-| Converts base path into a list of path elements.
|
||||
-}
|
||||
splitBasePath : String -> List String
|
||||
splitBasePath path =
|
||||
if path == "" then
|
||||
[]
|
||||
|
||||
else
|
||||
let
|
||||
stripSlashes str =
|
||||
if String.startsWith "/" str then
|
||||
stripSlashes (String.dropLeft 1 str)
|
||||
|
||||
else if String.endsWith "/" str then
|
||||
stripSlashes (String.dropRight 1 str)
|
||||
|
||||
else
|
||||
str
|
||||
|
||||
newPath =
|
||||
stripSlashes path
|
||||
in
|
||||
String.split "/" newPath
|
||||
22
ui/src/Data/AppConfig.elm
Normal file
22
ui/src/Data/AppConfig.elm
Normal file
@@ -0,0 +1,22 @@
|
||||
module Data.AppConfig exposing (AppConfig, decoder, default)
|
||||
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline as P
|
||||
|
||||
|
||||
type alias AppConfig =
|
||||
{ basePath : String
|
||||
, monitorVisible : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : D.Decoder AppConfig
|
||||
decoder =
|
||||
D.succeed AppConfig
|
||||
|> P.optional "base-path" D.string ""
|
||||
|> P.required "monitor-visible" D.bool
|
||||
|
||||
|
||||
default : AppConfig
|
||||
default =
|
||||
AppConfig "" True
|
||||
11
ui/src/Data/Date.elm
Normal file
11
ui/src/Data/Date.elm
Normal file
@@ -0,0 +1,11 @@
|
||||
module Data.Date exposing (date)
|
||||
|
||||
import Json.Decode exposing (Decoder, int, map)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
{-| Decode a POSIX milliseconds timestamp.
|
||||
-}
|
||||
date : Decoder Posix
|
||||
date =
|
||||
int |> map Time.millisToPosix
|
||||
69
ui/src/Data/Message.elm
Normal file
69
ui/src/Data/Message.elm
Normal file
@@ -0,0 +1,69 @@
|
||||
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (optional, required)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias Message =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : Posix
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
, text : String
|
||||
, html : String
|
||||
, attachments : List Attachment
|
||||
, errors : List Error
|
||||
}
|
||||
|
||||
|
||||
type alias Attachment =
|
||||
{ id : String
|
||||
, fileName : String
|
||||
, contentType : String
|
||||
}
|
||||
|
||||
|
||||
type alias Error =
|
||||
{ name : String
|
||||
, detail : String
|
||||
, severe : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Message
|
||||
decoder =
|
||||
succeed Message
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "posix-millis" date
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
|> required "text" string
|
||||
|> required "html" string
|
||||
|> required "attachments" (list attachmentDecoder)
|
||||
|> required "errors" (list errorDecoder)
|
||||
|
||||
|
||||
attachmentDecoder : Decoder Attachment
|
||||
attachmentDecoder =
|
||||
succeed Attachment
|
||||
|> required "id" string
|
||||
|> required "filename" string
|
||||
|> required "content-type" string
|
||||
|
||||
|
||||
errorDecoder : Decoder Error
|
||||
errorDecoder =
|
||||
succeed Error
|
||||
|> required "name" string
|
||||
|> required "detail" string
|
||||
|> required "severe" bool
|
||||
31
ui/src/Data/MessageHeader.elm
Normal file
31
ui/src/Data/MessageHeader.elm
Normal file
@@ -0,0 +1,31 @@
|
||||
module Data.MessageHeader exposing (MessageHeader, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (optional, required)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias MessageHeader =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : Posix
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder MessageHeader
|
||||
decoder =
|
||||
succeed MessageHeader
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "posix-millis" date
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
70
ui/src/Data/Metrics.elm
Normal file
70
ui/src/Data/Metrics.elm
Normal file
@@ -0,0 +1,70 @@
|
||||
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (Decoder, int, map, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (requiredAt)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias Metrics =
|
||||
{ startTime : Posix
|
||||
, sysMem : Int
|
||||
, heapSize : Int
|
||||
, heapUsed : Int
|
||||
, heapObjects : Int
|
||||
, goRoutines : Int
|
||||
, webSockets : Int
|
||||
, smtpConnOpen : Int
|
||||
, smtpConnTotal : Int
|
||||
, smtpConnHist : List Int
|
||||
, smtpReceivedTotal : Int
|
||||
, smtpReceivedHist : List Int
|
||||
, smtpErrorsTotal : Int
|
||||
, smtpErrorsHist : List Int
|
||||
, smtpWarnsTotal : Int
|
||||
, smtpWarnsHist : List Int
|
||||
, retentionDeletesTotal : Int
|
||||
, retentionDeletesHist : List Int
|
||||
, retainedCount : Int
|
||||
, retainedCountHist : List Int
|
||||
, retainedSize : Int
|
||||
, retainedSizeHist : List Int
|
||||
, scanCompleted : Posix
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Metrics
|
||||
decoder =
|
||||
succeed Metrics
|
||||
|> requiredAt [ "startMillis" ] date
|
||||
|> requiredAt [ "memstats", "Sys" ] int
|
||||
|> requiredAt [ "memstats", "HeapSys" ] int
|
||||
|> requiredAt [ "memstats", "HeapAlloc" ] int
|
||||
|> requiredAt [ "memstats", "HeapObjects" ] int
|
||||
|> requiredAt [ "goroutines" ] int
|
||||
|> requiredAt [ "http", "WebSocketConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ReceivedTotal" ] int
|
||||
|> requiredAt [ "smtp", "ReceivedHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ErrorsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ErrorsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "WarnsTotal" ] int
|
||||
|> requiredAt [ "smtp", "WarnsHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "DeletesTotal" ] int
|
||||
|> requiredAt [ "retention", "DeletesHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedCurrent" ] int
|
||||
|> requiredAt [ "retention", "RetainedHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedSize" ] int
|
||||
|> requiredAt [ "retention", "SizeHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "ScanCompletedMillis" ] date
|
||||
|
||||
|
||||
{-| Decodes Inbuckets hacky comma-separated-int JSON strings.
|
||||
-}
|
||||
decodeIntList : Decoder (List Int)
|
||||
decodeIntList =
|
||||
string
|
||||
|> map (String.split ",")
|
||||
|> map (List.map (String.toInt >> Maybe.withDefault 0))
|
||||
107
ui/src/Data/ServerConfig.elm
Normal file
107
ui/src/Data/ServerConfig.elm
Normal file
@@ -0,0 +1,107 @@
|
||||
module Data.ServerConfig exposing (ServerConfig, decoder, encode)
|
||||
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline as P
|
||||
import Json.Encode as E
|
||||
|
||||
|
||||
|
||||
-- Generated by https://github.com/jhillyerd/go-to-elm-json
|
||||
|
||||
|
||||
type alias ServerConfig =
|
||||
{ version : String
|
||||
, buildDate : String
|
||||
, pop3Listener : String
|
||||
, webListener : String
|
||||
, smtpConfig : SmtpConfig
|
||||
, storageConfig : StorageConfig
|
||||
}
|
||||
|
||||
|
||||
type alias SmtpConfig =
|
||||
{ addr : String
|
||||
, defaultAccept : Bool
|
||||
, acceptDomains : Maybe (List String)
|
||||
, rejectDomains : Maybe (List String)
|
||||
, defaultStore : Bool
|
||||
, storeDomains : Maybe (List String)
|
||||
, discardDomains : Maybe (List String)
|
||||
}
|
||||
|
||||
|
||||
type alias StorageConfig =
|
||||
{ mailboxMsgCap : Int
|
||||
, storeType : String
|
||||
, retentionPeriod : String
|
||||
}
|
||||
|
||||
|
||||
decoder : D.Decoder ServerConfig
|
||||
decoder =
|
||||
D.succeed ServerConfig
|
||||
|> P.required "version" D.string
|
||||
|> P.required "build-date" D.string
|
||||
|> P.required "pop3-listener" D.string
|
||||
|> P.required "web-listener" D.string
|
||||
|> P.required "smtp-config" smtpConfigDecoder
|
||||
|> P.required "storage-config" storageConfigDecoder
|
||||
|
||||
|
||||
encode : ServerConfig -> E.Value
|
||||
encode r =
|
||||
E.object
|
||||
[ ( "version", E.string r.version )
|
||||
, ( "build-date", E.string r.buildDate )
|
||||
, ( "pop3-listener", E.string r.pop3Listener )
|
||||
, ( "web-listener", E.string r.webListener )
|
||||
, ( "smtp-config", encodeSmtpConfig r.smtpConfig )
|
||||
, ( "storage-config", encodeStorageConfig r.storageConfig )
|
||||
]
|
||||
|
||||
|
||||
smtpConfigDecoder : D.Decoder SmtpConfig
|
||||
smtpConfigDecoder =
|
||||
D.succeed SmtpConfig
|
||||
|> P.required "addr" D.string
|
||||
|> P.required "default-accept" D.bool
|
||||
|> P.required "accept-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "reject-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "default-store" D.bool
|
||||
|> P.required "store-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "discard-domains" (D.nullable (D.list D.string))
|
||||
|
||||
|
||||
encodeSmtpConfig : SmtpConfig -> E.Value
|
||||
encodeSmtpConfig r =
|
||||
E.object
|
||||
[ ( "addr", E.string r.addr )
|
||||
, ( "default-accept", E.bool r.defaultAccept )
|
||||
, ( "accept-domains", maybe (E.list E.string) r.acceptDomains )
|
||||
, ( "reject-domains", maybe (E.list E.string) r.rejectDomains )
|
||||
, ( "default-store", E.bool r.defaultStore )
|
||||
, ( "store-domains", maybe (E.list E.string) r.storeDomains )
|
||||
, ( "discard-domains", maybe (E.list E.string) r.discardDomains )
|
||||
]
|
||||
|
||||
|
||||
storageConfigDecoder : D.Decoder StorageConfig
|
||||
storageConfigDecoder =
|
||||
D.succeed StorageConfig
|
||||
|> P.required "mailbox-msg-cap" D.int
|
||||
|> P.required "store-type" D.string
|
||||
|> P.required "retention-period" D.string
|
||||
|
||||
|
||||
encodeStorageConfig : StorageConfig -> E.Value
|
||||
encodeStorageConfig r =
|
||||
E.object
|
||||
[ ( "mailbox-msg-cap", E.int r.mailboxMsgCap )
|
||||
, ( "store-type", E.string r.storeType )
|
||||
, ( "retention-period", E.string r.retentionPeriod )
|
||||
]
|
||||
|
||||
|
||||
maybe : (a -> E.Value) -> Maybe a -> E.Value
|
||||
maybe encoder =
|
||||
Maybe.map encoder >> Maybe.withDefault E.null
|
||||
124
ui/src/Data/Session.elm
Normal file
124
ui/src/Data/Session.elm
Normal file
@@ -0,0 +1,124 @@
|
||||
module Data.Session exposing
|
||||
( Flash
|
||||
, Persistent
|
||||
, Session
|
||||
, addRecent
|
||||
, clearFlash
|
||||
, decoder
|
||||
, disableRouting
|
||||
, enableRouting
|
||||
, encode
|
||||
, init
|
||||
, initError
|
||||
, showFlash
|
||||
)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline exposing (optional)
|
||||
import Json.Encode as E
|
||||
import Route exposing (Router)
|
||||
import Time
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type alias Session =
|
||||
{ key : Nav.Key
|
||||
, host : String
|
||||
, flash : Maybe Flash
|
||||
, routing : Bool
|
||||
, router : Router
|
||||
, zone : Time.Zone
|
||||
, config : AppConfig
|
||||
, persistent : Persistent
|
||||
}
|
||||
|
||||
|
||||
type alias Flash =
|
||||
{ title : String
|
||||
, table : List ( String, String )
|
||||
}
|
||||
|
||||
|
||||
type alias Persistent =
|
||||
{ recentMailboxes : List String
|
||||
}
|
||||
|
||||
|
||||
init : Nav.Key -> Url -> AppConfig -> Persistent -> Session
|
||||
init key location config persistent =
|
||||
{ key = key
|
||||
, host = location.host
|
||||
, flash = Nothing
|
||||
, routing = True
|
||||
, router = Route.newRouter config.basePath
|
||||
, zone = Time.utc
|
||||
, config = config
|
||||
, persistent = persistent
|
||||
}
|
||||
|
||||
|
||||
initError : Nav.Key -> Url -> String -> Session
|
||||
initError key location error =
|
||||
{ key = key
|
||||
, host = location.host
|
||||
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
||||
, routing = True
|
||||
, router = Route.newRouter ""
|
||||
, zone = Time.utc
|
||||
, config = AppConfig.default
|
||||
, persistent = Persistent []
|
||||
}
|
||||
|
||||
|
||||
addRecent : String -> Session -> Session
|
||||
addRecent mailbox session =
|
||||
if List.head session.persistent.recentMailboxes == Just mailbox then
|
||||
session
|
||||
|
||||
else
|
||||
let
|
||||
recent =
|
||||
session.persistent.recentMailboxes
|
||||
|> List.filter ((/=) mailbox)
|
||||
|> List.take 7
|
||||
|> (::) mailbox
|
||||
|
||||
persistent =
|
||||
session.persistent
|
||||
in
|
||||
{ session | persistent = { persistent | recentMailboxes = recent } }
|
||||
|
||||
|
||||
disableRouting : Session -> Session
|
||||
disableRouting session =
|
||||
{ session | routing = False }
|
||||
|
||||
|
||||
enableRouting : Session -> Session
|
||||
enableRouting session =
|
||||
{ session | routing = True }
|
||||
|
||||
|
||||
clearFlash : Session -> Session
|
||||
clearFlash session =
|
||||
{ session | flash = Nothing }
|
||||
|
||||
|
||||
showFlash : Flash -> Session -> Session
|
||||
showFlash flash session =
|
||||
{ session | flash = Just flash }
|
||||
|
||||
|
||||
decoder : D.Decoder Persistent
|
||||
decoder =
|
||||
D.succeed Persistent
|
||||
|> optional "recentMailboxes" (D.list D.string) []
|
||||
|
||||
|
||||
encode : Persistent -> E.Value
|
||||
encode persistent =
|
||||
E.object
|
||||
[ ( "recentMailboxes", E.list E.string persistent.recentMailboxes )
|
||||
]
|
||||
370
ui/src/Effect.elm
Normal file
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)
|
||||
132
ui/src/HttpUtil.elm
Normal file
132
ui/src/HttpUtil.elm
Normal file
@@ -0,0 +1,132 @@
|
||||
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
|
||||
|
||||
import Data.Session as Session
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
|
||||
|
||||
type alias Error =
|
||||
{ error : Http.Error
|
||||
, request : RequestContext
|
||||
}
|
||||
|
||||
|
||||
type alias RequestContext =
|
||||
{ method : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
|
||||
delete : (Result Error () -> msg) -> String -> Cmd msg
|
||||
delete msg url =
|
||||
let
|
||||
context =
|
||||
{ method = "DELETE"
|
||||
, url = url
|
||||
}
|
||||
in
|
||||
Http.request
|
||||
{ method = context.method
|
||||
, headers = []
|
||||
, url = url
|
||||
, body = Http.emptyBody
|
||||
, expect = expectWhatever context msg
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
patch : (Result Error () -> msg) -> String -> Http.Body -> Cmd msg
|
||||
patch msg url body =
|
||||
let
|
||||
context =
|
||||
{ method = "PATCH"
|
||||
, url = url
|
||||
}
|
||||
in
|
||||
Http.request
|
||||
{ method = context.method
|
||||
, headers = []
|
||||
, url = url
|
||||
, body = body
|
||||
, expect = expectWhatever context msg
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
errorFlash : Error -> Session.Flash
|
||||
errorFlash error =
|
||||
let
|
||||
requestContext flash =
|
||||
{ flash
|
||||
| table =
|
||||
flash.table
|
||||
++ [ ( "Request URL", error.request.url )
|
||||
, ( "HTTP Method", error.request.method )
|
||||
]
|
||||
}
|
||||
in
|
||||
requestContext <|
|
||||
case error.error of
|
||||
Http.BadUrl str ->
|
||||
{ title = "Bad URL"
|
||||
, table = [ ( "URL", str ) ]
|
||||
}
|
||||
|
||||
Http.Timeout ->
|
||||
{ title = "HTTP timeout"
|
||||
, table = []
|
||||
}
|
||||
|
||||
Http.NetworkError ->
|
||||
{ title = "HTTP network error"
|
||||
, table = []
|
||||
}
|
||||
|
||||
Http.BadStatus res ->
|
||||
{ title = "HTTP response error"
|
||||
, table = [ ( "Response Code", String.fromInt res ) ]
|
||||
}
|
||||
|
||||
Http.BadBody body ->
|
||||
{ title = "Bad HTTP body"
|
||||
, table = [ ( "Error", body ) ]
|
||||
}
|
||||
|
||||
|
||||
expectJson : RequestContext -> (Result Error a -> msg) -> Decode.Decoder a -> Http.Expect msg
|
||||
expectJson context toMsg decoder =
|
||||
Http.expectStringResponse toMsg <|
|
||||
resolve context <|
|
||||
\string ->
|
||||
Result.mapError Decode.errorToString (Decode.decodeString decoder string)
|
||||
|
||||
|
||||
expectString : RequestContext -> (Result Error String -> msg) -> Http.Expect msg
|
||||
expectString context toMsg =
|
||||
Http.expectStringResponse toMsg (resolve context Ok)
|
||||
|
||||
|
||||
expectWhatever : RequestContext -> (Result Error () -> msg) -> Http.Expect msg
|
||||
expectWhatever context toMsg =
|
||||
Http.expectBytesResponse toMsg (resolve context (\_ -> Ok ()))
|
||||
|
||||
|
||||
resolve : RequestContext -> (body -> Result String a) -> Http.Response body -> Result Error a
|
||||
resolve context toResult response =
|
||||
case response of
|
||||
Http.BadUrl_ url ->
|
||||
Err (Error (Http.BadUrl url) context)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err (Error Http.Timeout context)
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err (Error Http.NetworkError context)
|
||||
|
||||
Http.BadStatus_ metadata _ ->
|
||||
Err (Error (Http.BadStatus metadata.statusCode) context)
|
||||
|
||||
Http.GoodStatus_ _ body ->
|
||||
Result.mapError (\x -> Error (Http.BadBody x) context) (toResult body)
|
||||
309
ui/src/Layout.elm
Normal file
309
ui/src/Layout.elm
Normal file
@@ -0,0 +1,309 @@
|
||||
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
||||
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Effect exposing (Effect)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, a
|
||||
, button
|
||||
, div
|
||||
, footer
|
||||
, form
|
||||
, h2
|
||||
, header
|
||||
, i
|
||||
, input
|
||||
, li
|
||||
, nav
|
||||
, pre
|
||||
, span
|
||||
, td
|
||||
, text
|
||||
, th
|
||||
, tr
|
||||
, ul
|
||||
)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( attribute
|
||||
, class
|
||||
, classList
|
||||
, href
|
||||
, placeholder
|
||||
, rel
|
||||
, target
|
||||
, type_
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import Modal
|
||||
import Route
|
||||
import Timer exposing (Timer)
|
||||
|
||||
|
||||
{-| Used to highlight current page in navbar.
|
||||
-}
|
||||
type Page
|
||||
= Other
|
||||
| Mailbox
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
type alias Model msg =
|
||||
{ mapMsg : Msg -> msg
|
||||
, mainMenuVisible : Bool
|
||||
, recentMenuVisible : Bool
|
||||
, recentMenuTimer : Timer
|
||||
, mailboxName : String
|
||||
}
|
||||
|
||||
|
||||
init : (Msg -> msg) -> Model msg
|
||||
init mapMsg =
|
||||
{ mapMsg = mapMsg
|
||||
, mainMenuVisible = False
|
||||
, recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.empty
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
|
||||
{-| Resets layout state, used when navigating to a new page.
|
||||
-}
|
||||
reset : Model msg -> Model msg
|
||||
reset model =
|
||||
{ model
|
||||
| mainMenuVisible = False
|
||||
, recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= ClearFlash
|
||||
| MainMenuToggled
|
||||
| ModalFocused Modal.Msg
|
||||
| ModalUnfocused
|
||||
| OnMailboxNameInput String
|
||||
| OpenMailbox
|
||||
| RecentMenuMouseOver
|
||||
| RecentMenuMouseOut
|
||||
| RecentMenuTimeout Timer
|
||||
| RecentMenuToggled
|
||||
|
||||
|
||||
update : Msg -> Model msg -> ( Model msg, Effect msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ClearFlash ->
|
||||
( model, Effect.clearFlash )
|
||||
|
||||
MainMenuToggled ->
|
||||
( { model | mainMenuVisible = not model.mainMenuVisible }, Effect.none )
|
||||
|
||||
ModalFocused message ->
|
||||
( model, Effect.focusModalResult message )
|
||||
|
||||
ModalUnfocused ->
|
||||
( model, Effect.focusModal (ModalFocused >> model.mapMsg) )
|
||||
|
||||
OnMailboxNameInput name ->
|
||||
( { model | mailboxName = name }, Effect.none )
|
||||
|
||||
OpenMailbox ->
|
||||
if model.mailboxName == "" then
|
||||
( model, Effect.none )
|
||||
|
||||
else
|
||||
( model
|
||||
, Effect.navigateRoute True (Route.Mailbox model.mailboxName)
|
||||
)
|
||||
|
||||
RecentMenuMouseOver ->
|
||||
( { model
|
||||
| recentMenuVisible = True
|
||||
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||
}
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
RecentMenuMouseOut ->
|
||||
let
|
||||
-- Keep the recent menu open for a moment even if the mouse leaves it.
|
||||
newTimer =
|
||||
Timer.replace model.recentMenuTimer
|
||||
in
|
||||
( { model
|
||||
| recentMenuTimer = newTimer
|
||||
}
|
||||
, Effect.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
|
||||
)
|
||||
|
||||
RecentMenuTimeout timer ->
|
||||
if timer == model.recentMenuTimer then
|
||||
( { model
|
||||
| recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.cancel timer
|
||||
}
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
else
|
||||
-- Timer was no longer valid.
|
||||
( model, Effect.none )
|
||||
|
||||
RecentMenuToggled ->
|
||||
( { model | recentMenuVisible = not model.recentMenuVisible }
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
|
||||
type alias State msg =
|
||||
{ model : Model msg
|
||||
, session : Session
|
||||
, activePage : Page
|
||||
, activeMailbox : String
|
||||
, modal : Maybe (Html msg)
|
||||
, content : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
frame : State msg -> Html msg
|
||||
frame { model, session, activePage, activeMailbox, modal, content } =
|
||||
div [ class "app" ]
|
||||
[ header []
|
||||
[ nav [ class "navbar" ]
|
||||
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
|
||||
[ i [ class "fas fa-bars" ] [] ]
|
||||
, span [ class "navbar-brand" ]
|
||||
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
|
||||
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
|
||||
[ if session.config.monitorVisible then
|
||||
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
|
||||
|
||||
else
|
||||
text ""
|
||||
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
|
||||
, navbarRecent activePage activeMailbox model session
|
||||
, li [ class "navbar-mailbox" ]
|
||||
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, placeholder "mailbox"
|
||||
, value model.mailboxName
|
||||
, Events.onInput (OnMailboxNameInput >> model.mapMsg)
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "navbar-bg" ] [ text "" ]
|
||||
, Modal.view (ModalUnfocused |> model.mapMsg) modal
|
||||
, div [ class "page" ] (errorFlash model session.flash :: content)
|
||||
, footer []
|
||||
[ div [ class "footer" ]
|
||||
[ externalLink "https://www.inbucket.org" "Inbucket"
|
||||
, text " is an open source project hosted on "
|
||||
, externalLink "https://github.com/inbucket/inbucket" "GitHub"
|
||||
, text "."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
|
||||
errorFlash model maybeFlash =
|
||||
let
|
||||
row ( heading, message ) =
|
||||
tr []
|
||||
[ th [] [ text (heading ++ ":") ]
|
||||
, td [] [ pre [] [ text message ] ]
|
||||
]
|
||||
in
|
||||
case maybeFlash of
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
Just flash ->
|
||||
div [ class "well well-error" ]
|
||||
[ div [ class "flash-header" ]
|
||||
[ h2 [] [ text flash.title ]
|
||||
, a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ]
|
||||
]
|
||||
, div [ class "flash-table" ] (List.map row flash.table)
|
||||
]
|
||||
|
||||
|
||||
externalLink : String -> String -> Html a
|
||||
externalLink url title =
|
||||
a [ href url, target "_blank", rel "noopener" ] [ text title ]
|
||||
|
||||
|
||||
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
|
||||
navbarLink page url linkContent activePage =
|
||||
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
||||
[ a [ href url ] linkContent ]
|
||||
|
||||
|
||||
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||
-}
|
||||
navbarRecent : Page -> String -> Model msg -> Session -> Html msg
|
||||
navbarRecent page activeMailbox model session =
|
||||
let
|
||||
-- Active means we are viewing a specific mailbox.
|
||||
active =
|
||||
page == Mailbox
|
||||
|
||||
-- Recent tab title is the name of the current mailbox when active.
|
||||
title =
|
||||
if active then
|
||||
activeMailbox
|
||||
|
||||
else
|
||||
"Recent Mailboxes"
|
||||
|
||||
-- Mailboxes to show in recent list, doesn't include active mailbox.
|
||||
recentMailboxes =
|
||||
if active then
|
||||
List.tail session.persistent.recentMailboxes |> Maybe.withDefault []
|
||||
|
||||
else
|
||||
session.persistent.recentMailboxes
|
||||
|
||||
recentLink mailbox =
|
||||
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
|
||||
in
|
||||
li
|
||||
[ class "navbar-dropdown-container"
|
||||
, classList [ ( "navbar-active", active ) ]
|
||||
, attribute "aria-haspopup" "true"
|
||||
, ariaExpanded model.recentMenuVisible
|
||||
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
|
||||
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
|
||||
]
|
||||
[ span [ class "navbar-dropdown" ]
|
||||
[ text title
|
||||
, button
|
||||
[ class "navbar-dropdown-button"
|
||||
, Events.onClick (RecentMenuToggled |> model.mapMsg)
|
||||
]
|
||||
[ i [ class "fas fa-chevron-down" ] [] ]
|
||||
]
|
||||
, div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
|
||||
]
|
||||
|
||||
|
||||
ariaExpanded : Bool -> Attribute msg
|
||||
ariaExpanded value =
|
||||
attribute "aria-expanded" <|
|
||||
if value then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
413
ui/src/Main.elm
Normal file
413
ui/src/Main.elm
Normal file
@@ -0,0 +1,413 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser exposing (Document, UrlRequest)
|
||||
import Browser.Navigation as Nav
|
||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Effect exposing (Effect)
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as D exposing (Value)
|
||||
import Layout
|
||||
import Page.Home as Home
|
||||
import Page.Mailbox as Mailbox
|
||||
import Page.Monitor as Monitor
|
||||
import Page.Status as Status
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
import Task
|
||||
import Time
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ layout : Layout.Model Msg
|
||||
, page : PageModel
|
||||
}
|
||||
|
||||
|
||||
type PageModel
|
||||
= Home Home.Model
|
||||
| Mailbox Mailbox.Model
|
||||
| Monitor Monitor.Model
|
||||
| Status Status.Model
|
||||
|
||||
|
||||
type alias InitConfig =
|
||||
{ appConfig : AppConfig
|
||||
, session : Session.Persistent
|
||||
}
|
||||
|
||||
|
||||
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init configValue location key =
|
||||
let
|
||||
configDecoder =
|
||||
D.map2 InitConfig
|
||||
(D.field "app-config" AppConfig.decoder)
|
||||
(D.field "session" Session.decoder)
|
||||
|
||||
session =
|
||||
case D.decodeValue configDecoder configValue of
|
||||
Ok config ->
|
||||
Session.init key location config.appConfig config.session
|
||||
|
||||
Err error ->
|
||||
Session.initError key location (D.errorToString error)
|
||||
|
||||
( subModel, _ ) =
|
||||
-- Home.init effect is discarded because this subModel will be immediately replaced
|
||||
-- when we change routes to the specified location.
|
||||
Home.init session
|
||||
|
||||
initModel =
|
||||
{ layout = Layout.init LayoutMsg
|
||||
, page = Home subModel
|
||||
}
|
||||
|
||||
route =
|
||||
session.router.fromUrl location
|
||||
in
|
||||
changeRouteTo route initModel
|
||||
|> Tuple.mapSecond (\cmd -> Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ])
|
||||
|
||||
|
||||
type Msg
|
||||
= UrlChanged Url
|
||||
| LinkClicked UrlRequest
|
||||
| SessionUpdated (Result D.Error Session.Persistent)
|
||||
| TimeZoneLoaded Time.Zone
|
||||
| LayoutMsg Layout.Msg
|
||||
| HomeMsg Home.Msg
|
||||
| MailboxMsg Mailbox.Msg
|
||||
| MonitorMsg Monitor.Msg
|
||||
| StatusMsg Status.Msg
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ pageSubscriptions model.page
|
||||
, Sub.map SessionUpdated sessionChange
|
||||
]
|
||||
|
||||
|
||||
sessionChange : Sub (Result D.Error Session.Persistent)
|
||||
sessionChange =
|
||||
Ports.onSessionChange (D.decodeValue Session.decoder)
|
||||
|
||||
|
||||
pageSubscriptions : PageModel -> Sub Msg
|
||||
pageSubscriptions page =
|
||||
case page of
|
||||
Mailbox subModel ->
|
||||
Sub.map MailboxMsg (Mailbox.subscriptions subModel)
|
||||
|
||||
Status subModel ->
|
||||
Sub.map StatusMsg (Status.subscriptions subModel)
|
||||
|
||||
_ ->
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
let
|
||||
session =
|
||||
getSession model
|
||||
|
||||
( newModel, cmd ) =
|
||||
updateMain msg model session
|
||||
|
||||
newSession =
|
||||
getSession newModel
|
||||
in
|
||||
if session.persistent == newSession.persistent then
|
||||
( newModel, cmd )
|
||||
|
||||
else
|
||||
-- Store updated persistent session.
|
||||
( newModel
|
||||
, Cmd.batch
|
||||
[ Ports.storeSession (Session.encode newSession.persistent)
|
||||
, cmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
{-| Handle global/navbar related msgs.
|
||||
-}
|
||||
updateMain : Msg -> Model -> Session -> ( Model, Cmd Msg )
|
||||
updateMain msg model session =
|
||||
case msg of
|
||||
LinkClicked req ->
|
||||
case req of
|
||||
Browser.Internal url ->
|
||||
case url.fragment of
|
||||
Just "" ->
|
||||
-- Anchor tag for accessibility purposes only, already handled.
|
||||
( model, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Nav.pushUrl session.key (Url.toString url) )
|
||||
|
||||
Browser.External url ->
|
||||
( model, Nav.load url )
|
||||
|
||||
UrlChanged url ->
|
||||
-- Responds to new browser URL.
|
||||
if session.routing then
|
||||
changeRouteTo (session.router.fromUrl url) model
|
||||
|
||||
else
|
||||
-- Skip once, but re-enable routing.
|
||||
( applyToModelSession Session.enableRouting model
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SessionUpdated (Ok persistent) ->
|
||||
( updateSession model { session | persistent = persistent }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SessionUpdated (Err error) ->
|
||||
let
|
||||
flash =
|
||||
{ title = "Error decoding session"
|
||||
, table = [ ( "Error", D.errorToString error ) ]
|
||||
}
|
||||
in
|
||||
( applyToModelSession (Session.showFlash flash) model
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
TimeZoneLoaded zone ->
|
||||
( updateSession model { session | zone = zone }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
LayoutMsg subMsg ->
|
||||
let
|
||||
( layout, effect ) =
|
||||
Layout.update subMsg model.layout
|
||||
in
|
||||
( { model | layout = layout }, effect ) |> performEffects
|
||||
|
||||
_ ->
|
||||
updatePage msg model |> performEffects
|
||||
|
||||
|
||||
{-| Delegate incoming messages to their respective sub-pages.
|
||||
-}
|
||||
updatePage : Msg -> Model -> ( Model, Effect Msg )
|
||||
updatePage msg model =
|
||||
case ( msg, model.page ) of
|
||||
( HomeMsg subMsg, Home subModel ) ->
|
||||
Home.update subMsg subModel
|
||||
|> updateWith Home HomeMsg model
|
||||
|
||||
( MailboxMsg subMsg, Mailbox subModel ) ->
|
||||
Mailbox.update subMsg subModel
|
||||
|> updateWith Mailbox MailboxMsg model
|
||||
|
||||
( MonitorMsg subMsg, Monitor subModel ) ->
|
||||
Monitor.update subMsg subModel
|
||||
|> updateWith Monitor MonitorMsg model
|
||||
|
||||
( StatusMsg subMsg, Status subModel ) ->
|
||||
Status.update subMsg subModel
|
||||
|> updateWith Status StatusMsg model
|
||||
|
||||
( _, _ ) ->
|
||||
-- Disregard messages destined for the wrong page.
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
|
||||
changeRouteTo route model =
|
||||
let
|
||||
session =
|
||||
Session.clearFlash (getSession model)
|
||||
|
||||
newModel =
|
||||
{ model | layout = Layout.reset model.layout }
|
||||
in
|
||||
performEffects <|
|
||||
case route of
|
||||
Route.Home ->
|
||||
Home.init session
|
||||
|> updateWith Home HomeMsg newModel
|
||||
|
||||
Route.Mailbox name ->
|
||||
Mailbox.init session name Nothing
|
||||
|> updateWith Mailbox MailboxMsg newModel
|
||||
|
||||
Route.Message mailbox id ->
|
||||
Mailbox.init session mailbox (Just id)
|
||||
|> updateWith Mailbox MailboxMsg newModel
|
||||
|
||||
Route.Monitor ->
|
||||
if session.config.monitorVisible then
|
||||
Monitor.init session
|
||||
|> updateWith Monitor MonitorMsg newModel
|
||||
|
||||
else
|
||||
let
|
||||
flash =
|
||||
{ title = "Disabled route requested"
|
||||
, table = [ ( "Error", "Monitor disabled by configuration." ) ]
|
||||
}
|
||||
in
|
||||
( applyToModelSession (Session.showFlash flash) newModel
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
Route.Status ->
|
||||
Status.init session
|
||||
|> updateWith Status StatusMsg newModel
|
||||
|
||||
Route.Unknown path ->
|
||||
-- Unknown routes display Home with an error flash.
|
||||
let
|
||||
flash =
|
||||
{ title = "Unknown route requested"
|
||||
, table = [ ( "Path", path ) ]
|
||||
}
|
||||
in
|
||||
Home.init (Session.showFlash flash session)
|
||||
|> updateWith Home HomeMsg newModel
|
||||
|
||||
|
||||
{-| Perform effects by updating model and/or producing Cmds to be executed.
|
||||
-}
|
||||
performEffects : ( Model, Effect Msg ) -> ( Model, Cmd Msg )
|
||||
performEffects ( model, effect ) =
|
||||
Effect.perform ( getSession model, effect )
|
||||
|> Tuple.mapFirst (\newSession -> updateSession model newSession)
|
||||
|
||||
|
||||
getSession : Model -> Session
|
||||
getSession model =
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
subModel.session
|
||||
|
||||
Mailbox subModel ->
|
||||
subModel.session
|
||||
|
||||
Monitor subModel ->
|
||||
subModel.session
|
||||
|
||||
Status subModel ->
|
||||
subModel.session
|
||||
|
||||
|
||||
updateSession : Model -> Session -> Model
|
||||
updateSession model session =
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
{ model | page = Home { subModel | session = session } }
|
||||
|
||||
Mailbox subModel ->
|
||||
{ model | page = Mailbox { subModel | session = session } }
|
||||
|
||||
Monitor subModel ->
|
||||
{ model | page = Monitor { subModel | session = session } }
|
||||
|
||||
Status subModel ->
|
||||
{ model | page = Status { subModel | session = session } }
|
||||
|
||||
|
||||
applyToModelSession : (Session -> Session) -> Model -> Model
|
||||
applyToModelSession f model =
|
||||
updateSession model (f (getSession model))
|
||||
|
||||
|
||||
{-| Map page updates to Main Model and Msg types.
|
||||
-}
|
||||
updateWith :
|
||||
(subModel -> PageModel)
|
||||
-> (subMsg -> Msg)
|
||||
-> Model
|
||||
-> ( subModel, Effect subMsg )
|
||||
-> ( Model, Effect Msg )
|
||||
updateWith toPage toMsg model ( subModel, subEffect ) =
|
||||
( { model | page = toPage subModel }
|
||||
, Effect.map toMsg subEffect
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
let
|
||||
session =
|
||||
getSession model
|
||||
|
||||
mailbox =
|
||||
case model.page of
|
||||
Mailbox subModel ->
|
||||
subModel.mailboxName
|
||||
|
||||
_ ->
|
||||
""
|
||||
|
||||
framePage :
|
||||
Layout.Page
|
||||
-> (msg -> Msg)
|
||||
-> { title : String, modal : Maybe (Html msg), content : List (Html msg) }
|
||||
-> Document Msg
|
||||
framePage page toMsg { title, modal, content } =
|
||||
Document title
|
||||
[ Layout.frame
|
||||
{ model = model.layout
|
||||
, session = session
|
||||
, activePage = page
|
||||
, activeMailbox = mailbox
|
||||
, modal = Maybe.map (Html.map toMsg) modal
|
||||
, content = List.map (Html.map toMsg) content
|
||||
}
|
||||
]
|
||||
in
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
framePage Layout.Other HomeMsg (Home.view subModel)
|
||||
|
||||
Mailbox subModel ->
|
||||
framePage Layout.Mailbox MailboxMsg (Mailbox.view subModel)
|
||||
|
||||
Monitor subModel ->
|
||||
framePage Layout.Monitor MonitorMsg (Monitor.view subModel)
|
||||
|
||||
Status subModel ->
|
||||
framePage Layout.Status StatusMsg (Status.view subModel)
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Value Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlChange = UrlChanged
|
||||
, onUrlRequest = LinkClicked
|
||||
}
|
||||
58
ui/src/Modal.elm
Normal file
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"
|
||||
59
ui/src/Page/Home.elm
Normal file
59
ui/src/Page/Home.elm
Normal file
@@ -0,0 +1,59 @@
|
||||
module Page.Home exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Data.Session exposing (Session)
|
||||
import Effect exposing (Effect)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes exposing (class, property)
|
||||
import HttpUtil
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, greeting : String
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Effect Msg )
|
||||
init session =
|
||||
( Model session "", Effect.getGreeting GreetingLoaded )
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= GreetingLoaded (Result HttpUtil.Error String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Effect Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GreetingLoaded (Ok greeting) ->
|
||||
( { model | greeting = greeting }, Effect.none )
|
||||
|
||||
GreetingLoaded (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ Html.node "rendered-html"
|
||||
[ class "greeting"
|
||||
, property "content" (Encode.string model.greeting)
|
||||
]
|
||||
[]
|
||||
]
|
||||
}
|
||||
775
ui/src/Page/Mailbox.elm
Normal file
775
ui/src/Page/Mailbox.elm
Normal file
@@ -0,0 +1,775 @@
|
||||
module Page.Mailbox exposing (Model, Msg, init, subscriptions, update, view)
|
||||
|
||||
import Api
|
||||
import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader exposing (MessageHeader)
|
||||
import Data.Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import DateFormat.Relative as Relative
|
||||
import Effect exposing (Effect)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, a
|
||||
, article
|
||||
, aside
|
||||
, button
|
||||
, dd
|
||||
, div
|
||||
, dl
|
||||
, dt
|
||||
, h3
|
||||
, i
|
||||
, input
|
||||
, li
|
||||
, main_
|
||||
, nav
|
||||
, p
|
||||
, span
|
||||
, table
|
||||
, td
|
||||
, text
|
||||
, tr
|
||||
, ul
|
||||
)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( alt
|
||||
, class
|
||||
, classList
|
||||
, disabled
|
||||
, download
|
||||
, href
|
||||
, placeholder
|
||||
, property
|
||||
, tabindex
|
||||
, target
|
||||
, type_
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import HttpUtil
|
||||
import Json.Decode as D
|
||||
import Json.Encode as E
|
||||
import Modal
|
||||
import Route
|
||||
import Time exposing (Posix)
|
||||
import Timer exposing (Timer)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type Body
|
||||
= TextBody
|
||||
| SafeHtmlBody
|
||||
|
||||
|
||||
type State
|
||||
= LoadingList (Maybe MessageID)
|
||||
| ShowingList MessageList MessageState
|
||||
|
||||
|
||||
type MessageState
|
||||
= NoMessage
|
||||
| LoadingMessage
|
||||
| ShowingMessage Message
|
||||
| Transitioning Message
|
||||
|
||||
|
||||
type alias MessageID =
|
||||
String
|
||||
|
||||
|
||||
type alias MessageList =
|
||||
{ headers : List MessageHeader
|
||||
, selected : Maybe MessageID
|
||||
, searchFilter : String
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, mailboxName : String
|
||||
, state : State
|
||||
, socketConnected : Bool
|
||||
, bodyMode : Body
|
||||
, searchInput : String
|
||||
, promptPurge : Bool
|
||||
, markSeenTimer : Timer
|
||||
, now : Posix
|
||||
}
|
||||
|
||||
|
||||
type alias ServeUrl =
|
||||
List String -> String
|
||||
|
||||
|
||||
init : Session -> String -> Maybe MessageID -> ( Model, Effect Msg )
|
||||
init session mailboxName selection =
|
||||
( { session = session
|
||||
, mailboxName = mailboxName
|
||||
, state = LoadingList selection
|
||||
, socketConnected = False
|
||||
, bodyMode = SafeHtmlBody
|
||||
, searchInput = ""
|
||||
, promptPurge = False
|
||||
, markSeenTimer = Timer.empty
|
||||
, now = Time.millisToPosix 0
|
||||
}
|
||||
, Effect.batch
|
||||
[ Effect.posixTime Tick
|
||||
, Effect.getHeaderList ListLoaded mailboxName
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions _ =
|
||||
Time.every (30 * 1000) Tick
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= ListLoaded (Result HttpUtil.Error (List MessageHeader))
|
||||
| ClickMessage MessageID
|
||||
| ClickRefresh
|
||||
| ListKeyPress String Int
|
||||
| CloseMessage
|
||||
| MessageLoaded (Result HttpUtil.Error Message)
|
||||
| MessageBody Body
|
||||
| MarkSeenTriggered Timer
|
||||
| MarkSeenLoaded (Result HttpUtil.Error ())
|
||||
| DeleteMessage Message
|
||||
| DeletedMessage (Result HttpUtil.Error ())
|
||||
| PurgeMailboxPrompt
|
||||
| PurgeMailboxCanceled
|
||||
| PurgeMailboxConfirmed
|
||||
| PurgedMailbox (Result HttpUtil.Error ())
|
||||
| OnSearchInput String
|
||||
| Tick Posix
|
||||
| ModalFocused Modal.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Effect Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ClickMessage id ->
|
||||
( updateSelected model id
|
||||
, Effect.batch
|
||||
[ -- Update browser location.
|
||||
Effect.updateRoute (Route.Message model.mailboxName id)
|
||||
, Effect.getMessage MessageLoaded model.mailboxName id
|
||||
]
|
||||
)
|
||||
|
||||
ClickRefresh ->
|
||||
let
|
||||
selection =
|
||||
case model.state of
|
||||
ShowingList _ (ShowingMessage message) ->
|
||||
Just message.id
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
in
|
||||
-- Reset to loading state, preserving the current message selection.
|
||||
( { model | state = LoadingList selection }
|
||||
, Effect.getHeaderList ListLoaded model.mailboxName
|
||||
)
|
||||
|
||||
CloseMessage ->
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
( { model | state = ShowingList list NoMessage }, Effect.none )
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
DeleteMessage message ->
|
||||
updateDeleteMessage model message
|
||||
|
||||
DeletedMessage (Ok _) ->
|
||||
( model, Effect.none )
|
||||
|
||||
DeletedMessage (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
ListKeyPress id keyCode ->
|
||||
case keyCode of
|
||||
13 ->
|
||||
updateOpenMessage model id
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
ListLoaded (Ok headers) ->
|
||||
updateListLoaded model headers
|
||||
|
||||
ListLoaded (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
MarkSeenLoaded (Ok _) ->
|
||||
( model, Effect.none )
|
||||
|
||||
MarkSeenLoaded (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
MessageLoaded (Ok message) ->
|
||||
updateMessageResult model message
|
||||
|
||||
MessageLoaded (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
MessageBody bodyMode ->
|
||||
( { model | bodyMode = bodyMode }, Effect.none )
|
||||
|
||||
ModalFocused message ->
|
||||
( model, Effect.focusModalResult message )
|
||||
|
||||
OnSearchInput searchInput ->
|
||||
updateSearchInput model searchInput
|
||||
|
||||
PurgeMailboxPrompt ->
|
||||
( { model | promptPurge = True }, Effect.focusModal ModalFocused )
|
||||
|
||||
PurgeMailboxCanceled ->
|
||||
( { model | promptPurge = False }, Effect.none )
|
||||
|
||||
PurgeMailboxConfirmed ->
|
||||
updateTriggerPurge model
|
||||
|
||||
PurgedMailbox (Ok _) ->
|
||||
( model, Effect.none )
|
||||
|
||||
PurgedMailbox (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
MarkSeenTriggered timer ->
|
||||
if timer == model.markSeenTimer then
|
||||
-- Matching timer means we have changed messages, mark this one seen.
|
||||
updateMarkMessageSeen model
|
||||
|
||||
else
|
||||
( model, Effect.none )
|
||||
|
||||
Tick now ->
|
||||
( { model | now = now }, Effect.none )
|
||||
|
||||
|
||||
updateListLoaded : Model -> List MessageHeader -> ( Model, Effect Msg )
|
||||
updateListLoaded model headers =
|
||||
case model.state of
|
||||
LoadingList selection ->
|
||||
let
|
||||
newModel =
|
||||
{ model
|
||||
| state = ShowingList (MessageList headers Nothing "") NoMessage
|
||||
}
|
||||
in
|
||||
Effect.append (Effect.addRecent newModel.mailboxName) <|
|
||||
case selection of
|
||||
Just id ->
|
||||
-- Don't try to load selected message if not present in headers.
|
||||
if List.any (\header -> Just header.id == selection) headers then
|
||||
updateOpenMessage newModel id
|
||||
|
||||
else
|
||||
( newModel, Effect.updateRoute (Route.Mailbox model.mailboxName) )
|
||||
|
||||
Nothing ->
|
||||
( newModel, Effect.none )
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
{-| Replace the currently displayed message.
|
||||
-}
|
||||
updateMessageResult : Model -> Message -> ( Model, Effect Msg )
|
||||
updateMessageResult model message =
|
||||
let
|
||||
bodyMode =
|
||||
if message.html == "" then
|
||||
TextBody
|
||||
|
||||
else
|
||||
model.bodyMode
|
||||
in
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
( model, Effect.none )
|
||||
|
||||
ShowingList list _ ->
|
||||
let
|
||||
newTimer =
|
||||
Timer.replace model.markSeenTimer
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList
|
||||
{ list | selected = Just message.id }
|
||||
(ShowingMessage message)
|
||||
, bodyMode = bodyMode
|
||||
, markSeenTimer = newTimer
|
||||
}
|
||||
-- Set 1500ms delay before reporting message as seen to backend.
|
||||
, Effect.schedule MarkSeenTriggered newTimer 1500
|
||||
)
|
||||
|
||||
|
||||
{-| Updates model and triggers commands to purge this mailbox.
|
||||
-}
|
||||
updateTriggerPurge : Model -> ( Model, Effect Msg )
|
||||
updateTriggerPurge model =
|
||||
( { model
|
||||
| promptPurge = False
|
||||
, state = ShowingList (MessageList [] Nothing "") NoMessage
|
||||
}
|
||||
, Effect.batch
|
||||
[ Effect.updateRoute (Route.Mailbox model.mailboxName)
|
||||
, Effect.purgeMailbox PurgedMailbox model.mailboxName
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
updateSearchInput : Model -> String -> ( Model, Effect Msg )
|
||||
updateSearchInput model searchInput =
|
||||
let
|
||||
searchFilter =
|
||||
if String.length searchInput > 1 then
|
||||
String.toLower searchInput
|
||||
|
||||
else
|
||||
""
|
||||
in
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
( model, Effect.none )
|
||||
|
||||
ShowingList list messageState ->
|
||||
( { model
|
||||
| searchInput = searchInput
|
||||
, state = ShowingList { list | searchFilter = searchFilter } messageState
|
||||
}
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
|
||||
{-| Set the selected message in our model.
|
||||
-}
|
||||
updateSelected : Model -> MessageID -> Model
|
||||
updateSelected model id =
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
model
|
||||
|
||||
ShowingList list messageState ->
|
||||
let
|
||||
newList =
|
||||
{ list | selected = Just id }
|
||||
in
|
||||
case messageState of
|
||||
NoMessage ->
|
||||
{ model | state = ShowingList newList LoadingMessage }
|
||||
|
||||
LoadingMessage ->
|
||||
{ model | state = ShowingList newList LoadingMessage }
|
||||
|
||||
ShowingMessage visible ->
|
||||
-- Use Transitioning state to prevent blank message flicker.
|
||||
{ model | state = ShowingList newList (Transitioning visible) }
|
||||
|
||||
Transitioning visible ->
|
||||
{ model | state = ShowingList newList (Transitioning visible) }
|
||||
|
||||
|
||||
updateDeleteMessage : Model -> Message -> ( Model, Effect Msg )
|
||||
updateDeleteMessage model message =
|
||||
let
|
||||
filter f messageList =
|
||||
{ messageList | headers = List.filter f messageList.headers }
|
||||
in
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
( { model | state = ShowingList (filter (\x -> x.id /= message.id) list) NoMessage }
|
||||
, Effect.batch
|
||||
[ Effect.deleteMessage DeletedMessage message.mailbox message.id
|
||||
, Effect.updateRoute (Route.Mailbox model.mailboxName)
|
||||
]
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
{-| Updates both the active message, and the message list to mark the currently viewed message as seen.
|
||||
-}
|
||||
updateMarkMessageSeen : Model -> ( Model, Effect Msg )
|
||||
updateMarkMessageSeen model =
|
||||
case model.state of
|
||||
ShowingList messages (ShowingMessage visibleMessage) ->
|
||||
let
|
||||
updateHeader header =
|
||||
if header.id == visibleMessage.id then
|
||||
{ header | seen = True }
|
||||
|
||||
else
|
||||
header
|
||||
|
||||
newMessages =
|
||||
{ messages | headers = List.map updateHeader messages.headers }
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
|
||||
}
|
||||
, Effect.markMessageSeen MarkSeenLoaded visibleMessage.mailbox visibleMessage.id
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
updateOpenMessage : Model -> String -> ( Model, Effect Msg )
|
||||
updateOpenMessage model id =
|
||||
( updateSelected model id
|
||||
, Effect.getMessage MessageLoaded model.mailboxName id
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html Msg), content : List (Html Msg) }
|
||||
view model =
|
||||
let
|
||||
serveUrl : ServeUrl
|
||||
serveUrl =
|
||||
Api.serveUrl model.session
|
||||
|
||||
mode =
|
||||
case model.state of
|
||||
ShowingList _ (ShowingMessage _) ->
|
||||
"message-active"
|
||||
|
||||
_ ->
|
||||
"list-active"
|
||||
in
|
||||
{ title = model.mailboxName ++ " - Inbucket"
|
||||
, modal = viewModal model.promptPurge
|
||||
, content =
|
||||
[ div [ class ("mailbox " ++ mode) ]
|
||||
[ viewMessageListControls model
|
||||
, viewMessageList model
|
||||
, main_
|
||||
[ class "message" ]
|
||||
[ case model.state of
|
||||
ShowingList _ NoMessage ->
|
||||
text
|
||||
("Select a message on the left,"
|
||||
++ " or enter a different username into the box on upper right."
|
||||
)
|
||||
|
||||
ShowingList _ (ShowingMessage message) ->
|
||||
viewMessage serveUrl model.session.zone message model.bodyMode
|
||||
|
||||
ShowingList _ (Transitioning message) ->
|
||||
viewMessage serveUrl model.session.zone message model.bodyMode
|
||||
|
||||
_ ->
|
||||
text ""
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewModal : Bool -> Maybe (Html Msg)
|
||||
viewModal promptPurge =
|
||||
if promptPurge then
|
||||
Just <|
|
||||
div []
|
||||
[ p [] [ text "Are you sure you want to delete all messages in this mailbox?" ]
|
||||
, div [ class "button-bar" ]
|
||||
[ button [ Events.onClick PurgeMailboxConfirmed, class "danger" ] [ text "Yes" ]
|
||||
, button [ Events.onClick PurgeMailboxCanceled ] [ text "Cancel" ]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
|
||||
viewMessageListControls : Model -> Html Msg
|
||||
viewMessageListControls model =
|
||||
let
|
||||
clearButton =
|
||||
Just <|
|
||||
button
|
||||
[ Events.onClick (OnSearchInput "")
|
||||
, disabled (model.searchInput == "")
|
||||
, alt "Clear Search"
|
||||
]
|
||||
[ i [ class "fas fa-times" ] [] ]
|
||||
|
||||
purgeButton =
|
||||
Just <|
|
||||
button
|
||||
[ Events.onClick PurgeMailboxPrompt
|
||||
, alt "Purge Mailbox"
|
||||
]
|
||||
[ i [ class "fas fa-trash" ] [] ]
|
||||
|
||||
refreshButton =
|
||||
if model.socketConnected then
|
||||
Nothing
|
||||
|
||||
else
|
||||
Just <|
|
||||
button
|
||||
[ Events.onClick ClickRefresh
|
||||
, alt "Refresh Mailbox"
|
||||
]
|
||||
[ i [ class "fas fa-sync" ] [] ]
|
||||
|
||||
searchInput =
|
||||
Just <|
|
||||
input
|
||||
[ type_ "text"
|
||||
, placeholder "search"
|
||||
, Events.onInput OnSearchInput
|
||||
, value model.searchInput
|
||||
]
|
||||
[]
|
||||
in
|
||||
[ searchInput, clearButton, refreshButton, purgeButton ]
|
||||
|> List.filterMap identity
|
||||
|> aside [ class "message-list-controls" ]
|
||||
|
||||
|
||||
viewMessageList : Model -> Html Msg
|
||||
viewMessageList model =
|
||||
aside [ class "message-list" ] <|
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
[]
|
||||
|
||||
ShowingList list _ ->
|
||||
list
|
||||
|> filterMessageList
|
||||
|> List.reverse
|
||||
|> List.map (messageChip model list.selected)
|
||||
|
||||
|
||||
messageChip : Model -> Maybe MessageID -> MessageHeader -> Html Msg
|
||||
messageChip model selected message =
|
||||
div
|
||||
[ class "message-list-entry"
|
||||
, classList
|
||||
[ ( "selected", selected == Just message.id )
|
||||
, ( "unseen", not message.seen )
|
||||
]
|
||||
, Events.onClick (ClickMessage message.id)
|
||||
, onKeyUp (ListKeyPress message.id)
|
||||
, tabindex 0
|
||||
]
|
||||
[ div [ class "subject" ] [ text message.subject ]
|
||||
, div [ class "from" ] [ text message.from ]
|
||||
, div [ class "date" ] [ relativeDate model message.date ]
|
||||
]
|
||||
|
||||
|
||||
viewMessage : ServeUrl -> Time.Zone -> Message -> Body -> Html Msg
|
||||
viewMessage serveUrl zone message bodyMode =
|
||||
let
|
||||
htmlUrl =
|
||||
serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
|
||||
|
||||
sourceUrl =
|
||||
serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
|
||||
|
||||
htmlButton =
|
||||
if message.html == "" then
|
||||
text ""
|
||||
|
||||
else
|
||||
a [ href htmlUrl, target "_blank" ]
|
||||
[ button [ tabindex -1 ] [ text "Raw HTML" ] ]
|
||||
in
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "message-close light", Events.onClick CloseMessage ]
|
||||
[ i [ class "fas fa-arrow-left" ] [] ]
|
||||
, button [ class "danger", Events.onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a [ href sourceUrl, target "_blank" ]
|
||||
[ button [ tabindex -1 ] [ text "Source" ] ]
|
||||
, htmlButton
|
||||
]
|
||||
, dl [ class "message-header" ]
|
||||
[ dt [] [ text "From:" ]
|
||||
, dd [] [ text message.from ]
|
||||
, dt [] [ text "To:" ]
|
||||
, dd [] [ text (String.join ", " message.to) ]
|
||||
, dt [] [ text "Date:" ]
|
||||
, dd [] [ verboseDate zone message.date ]
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, messageErrors message
|
||||
, messageBody message bodyMode
|
||||
, attachments serveUrl message
|
||||
]
|
||||
|
||||
|
||||
messageErrors : Message -> Html Msg
|
||||
messageErrors message =
|
||||
let
|
||||
row error =
|
||||
li []
|
||||
[ span
|
||||
[ classList [ ( "message-warn-severe", error.severe ) ] ]
|
||||
[ text (error.name ++ ": ") ]
|
||||
, text error.detail
|
||||
]
|
||||
in
|
||||
case message.errors of
|
||||
[] ->
|
||||
text ""
|
||||
|
||||
errors ->
|
||||
div [ class "well well-warn message-warn" ]
|
||||
[ div [] [ h3 [] [ text "MIME problems detected" ] ]
|
||||
, ul [] (List.map row errors)
|
||||
]
|
||||
|
||||
|
||||
messageBody : Message -> Body -> Html Msg
|
||||
messageBody message bodyMode =
|
||||
let
|
||||
bodyModeTab mode label =
|
||||
a
|
||||
[ classList [ ( "active", bodyMode == mode ) ]
|
||||
, Events.onClick (MessageBody mode)
|
||||
, href "#"
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
safeHtml =
|
||||
bodyModeTab SafeHtmlBody "Safe HTML"
|
||||
|
||||
plainText =
|
||||
bodyModeTab TextBody "Plain Text"
|
||||
|
||||
tabs =
|
||||
if message.html == "" then
|
||||
[ plainText ]
|
||||
|
||||
else
|
||||
[ safeHtml, plainText ]
|
||||
in
|
||||
div [ class "tab-panel" ]
|
||||
[ nav [ class "tab-bar" ] tabs
|
||||
, article [ class "message-body" ]
|
||||
[ case bodyMode of
|
||||
SafeHtmlBody ->
|
||||
Html.node "rendered-html" [ property "content" (E.string message.html) ] []
|
||||
|
||||
TextBody ->
|
||||
Html.node "rendered-html" [ property "content" (E.string message.text) ] []
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
attachments : ServeUrl -> Message -> Html Msg
|
||||
attachments serveUrl message =
|
||||
if List.isEmpty message.attachments then
|
||||
text ""
|
||||
|
||||
else
|
||||
message.attachments
|
||||
|> List.map (attachmentRow serveUrl message)
|
||||
|> table [ class "attachments well" ]
|
||||
|
||||
|
||||
attachmentRow : ServeUrl -> Message -> Message.Attachment -> Html Msg
|
||||
attachmentRow serveUrl message attach =
|
||||
let
|
||||
url =
|
||||
serveUrl
|
||||
[ "mailbox"
|
||||
, message.mailbox
|
||||
, message.id
|
||||
, "attach"
|
||||
, attach.id
|
||||
, attach.fileName
|
||||
]
|
||||
in
|
||||
tr []
|
||||
[ td []
|
||||
[ a [ href url, target "_blank" ] [ text attach.fileName ]
|
||||
, text (" (" ++ attach.contentType ++ ") ")
|
||||
]
|
||||
, td [] [ a [ href url, download attach.fileName, class "button" ] [ text "Download" ] ]
|
||||
]
|
||||
|
||||
|
||||
relativeDate : Model -> Posix -> Html Msg
|
||||
relativeDate model date =
|
||||
Relative.relativeTime model.now date |> text
|
||||
|
||||
|
||||
verboseDate : Time.Zone -> Posix -> Html Msg
|
||||
verboseDate zone date =
|
||||
text <|
|
||||
DF.format
|
||||
[ DF.monthNameFull
|
||||
, DF.text " "
|
||||
, DF.dayOfMonthSuffix
|
||||
, DF.text ", "
|
||||
, DF.yearNumber
|
||||
, DF.text " "
|
||||
, DF.hourNumber
|
||||
, DF.text ":"
|
||||
, DF.minuteFixed
|
||||
, DF.text ":"
|
||||
, DF.secondFixed
|
||||
, DF.text " "
|
||||
, DF.amPmUppercase
|
||||
, DF.text " (Local)"
|
||||
]
|
||||
zone
|
||||
date
|
||||
|
||||
|
||||
|
||||
-- UTILITY
|
||||
|
||||
|
||||
filterMessageList : MessageList -> List MessageHeader
|
||||
filterMessageList list =
|
||||
if list.searchFilter == "" then
|
||||
list.headers
|
||||
|
||||
else
|
||||
let
|
||||
matches header =
|
||||
String.contains list.searchFilter (String.toLower header.subject)
|
||||
|| String.contains list.searchFilter (String.toLower header.from)
|
||||
in
|
||||
List.filter matches list.headers
|
||||
|
||||
|
||||
onKeyUp : (Int -> msg) -> Attribute msg
|
||||
onKeyUp tagger =
|
||||
Events.on "keyup" (D.map tagger Events.keyCode)
|
||||
193
ui/src/Page/Monitor.elm
Normal file
193
ui/src/Page/Monitor.elm
Normal file
@@ -0,0 +1,193 @@
|
||||
module Page.Monitor exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Api
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import Effect exposing (Effect)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, button
|
||||
, div
|
||||
, em
|
||||
, h1
|
||||
, node
|
||||
, span
|
||||
, table
|
||||
, tbody
|
||||
, td
|
||||
, text
|
||||
, th
|
||||
, thead
|
||||
, tr
|
||||
)
|
||||
import Html.Attributes exposing (class, src, tabindex)
|
||||
import Html.Events as Events
|
||||
import Json.Decode as D
|
||||
import Route
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, connected : Bool
|
||||
, messages : List MessageHeader
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Effect Msg )
|
||||
init session =
|
||||
( Model session False [], Effect.none )
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= Connected Bool
|
||||
| MessageReceived D.Value
|
||||
| Clear
|
||||
| OpenMessage MessageHeader
|
||||
| MessageKeyPress MessageHeader Int
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Effect Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Connected True ->
|
||||
( { model | connected = True, messages = [] }, Effect.none )
|
||||
|
||||
Connected False ->
|
||||
( { model | connected = False }, Effect.none )
|
||||
|
||||
MessageReceived value ->
|
||||
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
|
||||
Ok header ->
|
||||
( { model | messages = header :: List.take 500 model.messages }
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
Err err ->
|
||||
let
|
||||
flash =
|
||||
{ title = "Message decoding failed"
|
||||
, table = [ ( "Error", D.errorToString err ) ]
|
||||
}
|
||||
in
|
||||
( model, Effect.showFlash flash )
|
||||
|
||||
Clear ->
|
||||
( { model | messages = [] }, Effect.none )
|
||||
|
||||
OpenMessage header ->
|
||||
openMessage header model
|
||||
|
||||
MessageKeyPress header keyCode ->
|
||||
case keyCode of
|
||||
13 ->
|
||||
openMessage header model
|
||||
|
||||
_ ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
openMessage : MessageHeader -> Model -> ( Model, Effect Msg )
|
||||
openMessage header model =
|
||||
( model
|
||||
, Effect.navigateRoute True (Route.Message header.mailbox header.id)
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket Monitor"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ h1 [] [ text "Monitor" ]
|
||||
, div [ class "monitor-header" ]
|
||||
[ span [ class "monitor-description" ]
|
||||
[ text "Messages will be listed here shortly after delivery. "
|
||||
, em []
|
||||
[ text
|
||||
(if model.connected then
|
||||
"Connected."
|
||||
|
||||
else
|
||||
"Disconnected!"
|
||||
)
|
||||
]
|
||||
]
|
||||
, span [ class "button-bar monitor-buttons" ]
|
||||
[ button [ Events.onClick Clear ] [ text "Clear" ]
|
||||
]
|
||||
]
|
||||
|
||||
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
|
||||
-- specified by `src`.
|
||||
, node "monitor-messages"
|
||||
[ src (Api.monitorUri model.session)
|
||||
, Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
|
||||
, Events.on "message" (D.map MessageReceived D.value)
|
||||
]
|
||||
[]
|
||||
, table [ class "monitor" ]
|
||||
[ thead []
|
||||
[ th [] [ text "Date" ]
|
||||
, th [ class "desktop" ] [ text "From" ]
|
||||
, th [] [ text "Mailbox" ]
|
||||
, th [] [ text "Subject" ]
|
||||
]
|
||||
, tbody [] (List.map (viewMessage model.session.zone) model.messages)
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewMessage : Time.Zone -> MessageHeader -> Html Msg
|
||||
viewMessage zone message =
|
||||
tr
|
||||
[ tabindex 0
|
||||
, Events.onClick (OpenMessage message)
|
||||
, onKeyUp (MessageKeyPress message)
|
||||
]
|
||||
[ td [] [ shortDate zone message.date ]
|
||||
, td [ class "desktop" ] [ text message.from ]
|
||||
, td [] [ text message.mailbox ]
|
||||
, td [] [ text message.subject ]
|
||||
]
|
||||
|
||||
|
||||
shortDate : Time.Zone -> Posix -> Html Msg
|
||||
shortDate zone date =
|
||||
DF.format
|
||||
[ DF.dayOfMonthFixed
|
||||
, DF.text "-"
|
||||
, DF.monthNameAbbreviated
|
||||
, DF.text " "
|
||||
, DF.hourNumber
|
||||
, DF.text ":"
|
||||
, DF.minuteFixed
|
||||
, DF.text ":"
|
||||
, DF.secondFixed
|
||||
, DF.text " "
|
||||
, DF.amPmUppercase
|
||||
]
|
||||
zone
|
||||
date
|
||||
|> text
|
||||
|
||||
|
||||
onKeyUp : (Int -> msg) -> Attribute msg
|
||||
onKeyUp tagger =
|
||||
Events.on "keyup" (D.map tagger Events.keyCode)
|
||||
555
ui/src/Page/Status.elm
Normal file
555
ui/src/Page/Status.elm
Normal file
@@ -0,0 +1,555 @@
|
||||
module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
|
||||
|
||||
import Data.Metrics exposing (Metrics)
|
||||
import Data.ServerConfig exposing (ServerConfig)
|
||||
import Data.Session exposing (Session)
|
||||
import DateFormat.Relative as Relative
|
||||
import Effect exposing (Effect)
|
||||
import Filesize
|
||||
import Html
|
||||
exposing
|
||||
( Html
|
||||
, div
|
||||
, h1
|
||||
, h2
|
||||
, i
|
||||
, text
|
||||
)
|
||||
import Html.Attributes exposing (class)
|
||||
import HttpUtil
|
||||
import Sparkline as Spark
|
||||
import Svg.Attributes as SvgAttrib
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, now : Posix
|
||||
, config : Maybe ServerConfig
|
||||
, metrics : Maybe Metrics
|
||||
, xCounter : Float
|
||||
, sysMem : Metric
|
||||
, heapSize : Metric
|
||||
, heapUsed : Metric
|
||||
, heapObjects : Metric
|
||||
, goRoutines : Metric
|
||||
, webSockets : Metric
|
||||
, smtpConnOpen : Metric
|
||||
, smtpConnTotal : Metric
|
||||
, smtpReceivedTotal : Metric
|
||||
, smtpErrorsTotal : Metric
|
||||
, smtpWarnsTotal : Metric
|
||||
, retentionDeletesTotal : Metric
|
||||
, retainedCount : Metric
|
||||
, retainedSize : Metric
|
||||
}
|
||||
|
||||
|
||||
type alias Metric =
|
||||
{ label : String
|
||||
, value : Int
|
||||
, formatter : Int -> String
|
||||
, graph : Spark.DataSet -> Html Msg
|
||||
, history : Spark.DataSet
|
||||
, minutes : Int
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Effect Msg )
|
||||
init session =
|
||||
( { session = session
|
||||
, now = Time.millisToPosix 0
|
||||
, config = Nothing
|
||||
, metrics = Nothing
|
||||
, xCounter = 60
|
||||
, sysMem = Metric "System Memory" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapSize = Metric "Heap Size" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapUsed = Metric "Heap Used" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapObjects = Metric "Heap # Objects" 0 fmtInt graphZero initDataSet 10
|
||||
, goRoutines = Metric "Goroutines" 0 fmtInt graphZero initDataSet 10
|
||||
, webSockets = Metric "Open WebSockets" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnOpen = Metric "Open Connections" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnTotal = Metric "Total Connections" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpReceivedTotal = Metric "Messages Received" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpErrorsTotal = Metric "Message Errors" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpWarnsTotal = Metric "Message Warnings" 0 fmtInt graphChange initDataSet 60
|
||||
, retentionDeletesTotal = Metric "Retention Deletes" 0 fmtInt graphChange initDataSet 60
|
||||
, retainedCount = Metric "Stored Messages" 0 fmtInt graphZero initDataSet 60
|
||||
, retainedSize = Metric "Store Size" 0 Filesize.format graphZero initDataSet 60
|
||||
}
|
||||
, Effect.batch
|
||||
[ Effect.posixTime Tick
|
||||
, Effect.getServerConfig ServerConfigLoaded
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
initDataSet : Spark.DataSet
|
||||
initDataSet =
|
||||
List.range 0 59
|
||||
|> List.map (\x -> ( toFloat x, 0 ))
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions _ =
|
||||
Time.every (10 * 1000) Tick
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= MetricsReceived (Result HttpUtil.Error Metrics)
|
||||
| ServerConfigLoaded (Result HttpUtil.Error ServerConfig)
|
||||
| Tick Posix
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Effect Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
MetricsReceived (Ok metrics) ->
|
||||
( updateMetrics metrics model, Effect.none )
|
||||
|
||||
MetricsReceived (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
ServerConfigLoaded (Ok config) ->
|
||||
( { model | config = Just config }, Effect.none )
|
||||
|
||||
ServerConfigLoaded (Err err) ->
|
||||
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||
|
||||
Tick time ->
|
||||
( { model | now = time }
|
||||
, Effect.getServerMetrics MetricsReceived
|
||||
)
|
||||
|
||||
|
||||
{-| Update all metrics in Model; increment xCounter.
|
||||
-}
|
||||
updateMetrics : Metrics -> Model -> Model
|
||||
updateMetrics metrics model =
|
||||
let
|
||||
x =
|
||||
model.xCounter
|
||||
in
|
||||
{ model
|
||||
| metrics = Just metrics
|
||||
, xCounter = x + 1
|
||||
, sysMem = updateLocalMetric model.sysMem x metrics.sysMem
|
||||
, heapSize = updateLocalMetric model.heapSize x metrics.heapSize
|
||||
, heapUsed = updateLocalMetric model.heapUsed x metrics.heapUsed
|
||||
, heapObjects = updateLocalMetric model.heapObjects x metrics.heapObjects
|
||||
, goRoutines = updateLocalMetric model.goRoutines x metrics.goRoutines
|
||||
, webSockets = updateLocalMetric model.webSockets x metrics.webSockets
|
||||
, smtpConnOpen = updateLocalMetric model.smtpConnOpen x metrics.smtpConnOpen
|
||||
, smtpConnTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpConnTotal
|
||||
metrics.smtpConnTotal
|
||||
metrics.smtpConnHist
|
||||
, smtpReceivedTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpReceivedTotal
|
||||
metrics.smtpReceivedTotal
|
||||
metrics.smtpReceivedHist
|
||||
, smtpErrorsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpErrorsTotal
|
||||
metrics.smtpErrorsTotal
|
||||
metrics.smtpErrorsHist
|
||||
, smtpWarnsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpWarnsTotal
|
||||
metrics.smtpWarnsTotal
|
||||
metrics.smtpWarnsHist
|
||||
, retentionDeletesTotal =
|
||||
updateRemoteTotal
|
||||
model.retentionDeletesTotal
|
||||
metrics.retentionDeletesTotal
|
||||
metrics.retentionDeletesHist
|
||||
, retainedCount =
|
||||
updateRemoteMetric
|
||||
model.retainedCount
|
||||
metrics.retainedCount
|
||||
metrics.retainedCountHist
|
||||
, retainedSize =
|
||||
updateRemoteMetric
|
||||
model.retainedSize
|
||||
metrics.retainedSize
|
||||
metrics.retainedSizeHist
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked locally.
|
||||
-}
|
||||
updateLocalMetric : Metric -> Float -> Int -> Metric
|
||||
updateLocalMetric metric x value =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
Maybe.withDefault [] (List.tail metric.history)
|
||||
++ [ ( x, toFloat value ) ]
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server.
|
||||
-}
|
||||
updateRemoteMetric : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteMetric metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server. Sparkline will chart changes to the
|
||||
total instead of its absolute value.
|
||||
-}
|
||||
updateRemoteTotal : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteTotal metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> changeList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket Status"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ h1 [] [ text "Status" ]
|
||||
, div [] (configPanel model.config :: metricPanels model)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
configPanel : Maybe ServerConfig -> Html Msg
|
||||
configPanel maybeConfig =
|
||||
let
|
||||
mailboxCap config =
|
||||
case config.storageConfig.mailboxMsgCap of
|
||||
0 ->
|
||||
"Unlimited"
|
||||
|
||||
cap ->
|
||||
String.fromInt cap ++ " messages per mailbox"
|
||||
|
||||
retentionPeriod config =
|
||||
case config.storageConfig.retentionPeriod of
|
||||
"" ->
|
||||
"Forever"
|
||||
|
||||
period ->
|
||||
period
|
||||
in
|
||||
case maybeConfig of
|
||||
Nothing ->
|
||||
text "Loading server config..."
|
||||
|
||||
Just config ->
|
||||
framePanel "Configuration"
|
||||
"fa-cog"
|
||||
[ textEntry "Version" (config.version ++ ", built on " ++ config.buildDate)
|
||||
, textEntry "SMTP Listener" config.smtpConfig.addr
|
||||
, textEntry "POP3 Listener" config.pop3Listener
|
||||
, textEntry "HTTP Listener" config.webListener
|
||||
, textEntry "Accept Policy" (acceptPolicy config)
|
||||
, textEntry "Store Policy" (storePolicy config)
|
||||
, textEntry "Store Type" config.storageConfig.storeType
|
||||
, textEntry "Message Cap" (mailboxCap config)
|
||||
, textEntry "Retention Period" (retentionPeriod config)
|
||||
]
|
||||
|
||||
|
||||
acceptPolicy : ServerConfig -> String
|
||||
acceptPolicy config =
|
||||
if config.smtpConfig.defaultAccept then
|
||||
"All domains"
|
||||
++ (case config.smtpConfig.rejectDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.smtpConfig.acceptDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
|
||||
storePolicy : ServerConfig -> String
|
||||
storePolicy config =
|
||||
if config.smtpConfig.defaultStore then
|
||||
"All domains"
|
||||
++ (case config.smtpConfig.discardDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.smtpConfig.storeDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
|
||||
metricPanels : Model -> List (Html Msg)
|
||||
metricPanels model =
|
||||
case model.metrics of
|
||||
Nothing ->
|
||||
[ text "Loading metrics..." ]
|
||||
|
||||
Just metrics ->
|
||||
[ framePanel "General Metrics"
|
||||
"fa-tachometer-alt"
|
||||
[ textEntry "Uptime" <|
|
||||
"Started "
|
||||
++ Relative.relativeTime model.now metrics.startTime
|
||||
, viewMetric model.sysMem
|
||||
, viewMetric model.heapSize
|
||||
, viewMetric model.heapUsed
|
||||
, viewMetric model.heapObjects
|
||||
, viewMetric model.goRoutines
|
||||
, viewMetric model.webSockets
|
||||
]
|
||||
, framePanel "SMTP Metrics"
|
||||
"fa-envelope"
|
||||
[ viewMetric model.smtpConnOpen
|
||||
, viewMetric model.smtpConnTotal
|
||||
, viewMetric model.smtpReceivedTotal
|
||||
, viewMetric model.smtpErrorsTotal
|
||||
, viewMetric model.smtpWarnsTotal
|
||||
]
|
||||
, framePanel "Storage Metrics"
|
||||
"fa-archive"
|
||||
[ textEntry "Retention Scan" (retentionScan model)
|
||||
, viewMetric model.retentionDeletesTotal
|
||||
, viewMetric model.retainedCount
|
||||
, viewMetric model.retainedSize
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
retentionScan : Model -> String
|
||||
retentionScan model =
|
||||
case ( model.config, model.metrics ) of
|
||||
( Just config, Just metrics ) ->
|
||||
case config.storageConfig.retentionPeriod of
|
||||
"" ->
|
||||
"Disabled"
|
||||
|
||||
_ ->
|
||||
case Time.posixToMillis metrics.scanCompleted of
|
||||
0 ->
|
||||
"Not completed"
|
||||
|
||||
_ ->
|
||||
"Completed " ++ Relative.relativeTime model.now metrics.scanCompleted
|
||||
|
||||
( _, _ ) ->
|
||||
"No data"
|
||||
|
||||
|
||||
textEntry : String -> String -> Html Msg
|
||||
textEntry name value =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text name ]
|
||||
, div [ class "text-value" ] [ text value ]
|
||||
]
|
||||
|
||||
|
||||
viewMetric : Metric -> Html Msg
|
||||
viewMetric metric =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text metric.label ]
|
||||
, div [ class "value" ] [ text (metric.formatter metric.value) ]
|
||||
, div [ class "graph" ]
|
||||
[ metric.graph metric.history
|
||||
, text (" (" ++ String.fromInt metric.minutes ++ "min)")
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
graphSize : Spark.Size
|
||||
graphSize =
|
||||
{ width = 180
|
||||
, height = 16
|
||||
, marginLR = 0
|
||||
, marginTB = 0
|
||||
}
|
||||
|
||||
|
||||
areaStyle : Spark.Param a -> Spark.Param a
|
||||
areaStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.fill "rgba(50,100,255,0.3)"
|
||||
, SvgAttrib.stroke "rgba(50,100,255,1.0)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
barStyle : Spark.Param a -> Spark.Param a
|
||||
barStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.fill "rgba(50,200,50,0.7)"
|
||||
]
|
||||
|
||||
|
||||
zeroStyle : Spark.Param a -> Spark.Param a
|
||||
zeroStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.stroke "rgba(0,0,0,0.2)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
|
||||
-}
|
||||
graphChange : Spark.DataSet -> Html a
|
||||
graphChange data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
Spark.sparkline graphSize
|
||||
[ Spark.Bar 2.5 data |> barStyle
|
||||
, Spark.ZeroLine |> zeroStyle
|
||||
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
{-| Zero based area graph, for charting absolute values relative to 0.
|
||||
-}
|
||||
graphZero : Spark.DataSet -> Html a
|
||||
graphZero data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
Spark.sparkline graphSize
|
||||
[ Spark.Area data |> areaStyle
|
||||
, Spark.ZeroLine |> zeroStyle
|
||||
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
framePanel : String -> String -> List (Html a) -> Html a
|
||||
framePanel name icon html =
|
||||
let
|
||||
fontIcon cn =
|
||||
i [ class ("fas " ++ cn) ] []
|
||||
in
|
||||
div [ class "metric-panel" ]
|
||||
[ h2 []
|
||||
[ fontIcon icon
|
||||
, text " "
|
||||
, text name
|
||||
]
|
||||
, div [ class "metrics" ] html
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- UTILS --
|
||||
|
||||
|
||||
{-| Compute difference between each Int in numbers.
|
||||
-}
|
||||
changeList : List Int -> List Int
|
||||
changeList numbers =
|
||||
let
|
||||
tail =
|
||||
List.tail numbers |> Maybe.withDefault []
|
||||
in
|
||||
List.map2 (-) tail numbers
|
||||
|
||||
|
||||
{-| Pad the front of a list with 0s to make it at least 60 elements long.
|
||||
-}
|
||||
zeroPadList : List Int -> List Int
|
||||
zeroPadList numbers =
|
||||
let
|
||||
needed =
|
||||
60 - List.length numbers
|
||||
in
|
||||
if needed > 0 then
|
||||
List.repeat needed 0 ++ numbers
|
||||
|
||||
else
|
||||
numbers
|
||||
|
||||
|
||||
{-| Format an Int with thousands separators.
|
||||
-}
|
||||
fmtInt : Int -> String
|
||||
fmtInt n =
|
||||
let
|
||||
-- thousands recursively inserts thousands separators.
|
||||
thousands str =
|
||||
if String.length str <= 3 then
|
||||
str
|
||||
|
||||
else
|
||||
thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str
|
||||
in
|
||||
thousands (String.fromInt n)
|
||||
12
ui/src/Ports.elm
Normal file
12
ui/src/Ports.elm
Normal file
@@ -0,0 +1,12 @@
|
||||
port module Ports exposing
|
||||
( onSessionChange
|
||||
, storeSession
|
||||
)
|
||||
|
||||
import Json.Encode exposing (Value)
|
||||
|
||||
|
||||
port onSessionChange : (Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
port storeSession : Value -> Cmd msg
|
||||
117
ui/src/Route.elm
Normal file
117
ui/src/Route.elm
Normal file
@@ -0,0 +1,117 @@
|
||||
module Route exposing (Route(..), Router, newRouter)
|
||||
|
||||
import Url exposing (Url)
|
||||
import Url.Builder as Builder
|
||||
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
|
||||
|
||||
|
||||
type Route
|
||||
= Unknown String
|
||||
| Home
|
||||
| Mailbox String
|
||||
| Message String String
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
type alias Router =
|
||||
{ fromUrl : Url -> Route
|
||||
, toPath : Route -> String
|
||||
}
|
||||
|
||||
|
||||
{-| Returns a configured Router.
|
||||
-}
|
||||
newRouter : String -> Router
|
||||
newRouter basePath =
|
||||
let
|
||||
newPath =
|
||||
prepareBasePath basePath
|
||||
in
|
||||
{ fromUrl = fromUrl newPath
|
||||
, toPath = toPath newPath
|
||||
}
|
||||
|
||||
|
||||
{-| Routes our application handles.
|
||||
-}
|
||||
routes : List (Parser (Route -> a) a)
|
||||
routes =
|
||||
[ map Home top
|
||||
, map Message (s "m" </> string </> string)
|
||||
, map Mailbox (s "m" </> string)
|
||||
, map Monitor (s "monitor")
|
||||
, map Status (s "status")
|
||||
]
|
||||
|
||||
|
||||
{-| Returns the Route for a given URL.
|
||||
-}
|
||||
fromUrl : String -> Url -> Route
|
||||
fromUrl basePath url =
|
||||
let
|
||||
relative =
|
||||
{ url | path = String.replace basePath "" url.path }
|
||||
in
|
||||
case Parser.parse (oneOf routes) relative of
|
||||
Nothing ->
|
||||
Unknown url.path
|
||||
|
||||
Just route ->
|
||||
route
|
||||
|
||||
|
||||
{-| Convert route to a URI.
|
||||
-}
|
||||
toPath : String -> Route -> String
|
||||
toPath basePath page =
|
||||
let
|
||||
pieces =
|
||||
case page of
|
||||
Unknown _ ->
|
||||
[]
|
||||
|
||||
Home ->
|
||||
[]
|
||||
|
||||
Mailbox name ->
|
||||
[ "m", name ]
|
||||
|
||||
Message mailbox id ->
|
||||
[ "m", mailbox, id ]
|
||||
|
||||
Monitor ->
|
||||
[ "monitor" ]
|
||||
|
||||
Status ->
|
||||
[ "status" ]
|
||||
in
|
||||
basePath ++ Builder.absolute pieces []
|
||||
|
||||
|
||||
{-| Make sure basePath starts with a slash and does not have trailing slashes.
|
||||
|
||||
"inbucket/" becomes "/inbucket", "" remains ""
|
||||
|
||||
-}
|
||||
prepareBasePath : String -> String
|
||||
prepareBasePath path =
|
||||
let
|
||||
stripSlashes str =
|
||||
if String.startsWith "/" str then
|
||||
stripSlashes (String.dropLeft 1 str)
|
||||
|
||||
else if String.endsWith "/" str then
|
||||
stripSlashes (String.dropRight 1 str)
|
||||
|
||||
else
|
||||
str
|
||||
|
||||
newPath =
|
||||
stripSlashes path
|
||||
in
|
||||
if newPath == "" then
|
||||
""
|
||||
|
||||
else
|
||||
"/" ++ newPath
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user