mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 10:07:02 +00:00
Compare commits
321 Commits
v2.0.0-rc1
...
v3.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
62406f05e8 | ||
|
|
2e49b591eb | ||
|
|
7d7e408bfa | ||
|
|
91fea4e1fd | ||
|
|
469132fe2f | ||
|
|
690b19a22c | ||
|
|
30e3892cb0 | ||
|
|
fcb4bc20e0 | ||
|
|
8a3d2ff6a2 | ||
|
|
2f67a6922a | ||
|
|
82e6a9fe5d | ||
|
|
1a7e47b60a | ||
|
|
4d17886ed6 | ||
|
|
0640f9fa08 | ||
|
|
f68f07d896 | ||
|
|
98745b3bb9 | ||
|
|
5e8f00fe0b | ||
|
|
f9adced65e | ||
|
|
dc007da82e | ||
|
|
bf12925fd1 | ||
|
|
0d7c94c531 | ||
|
|
00dad88bde | ||
|
|
fdcb29a52b | ||
|
|
894db04d70 | ||
|
|
58c3e17be7 | ||
|
|
30d8d6c64f | ||
|
|
37361e08e8 | ||
|
|
2ceb510f70 | ||
|
|
62fa52f42c | ||
|
|
568474da32 | ||
|
|
562332258d | ||
|
|
941b682197 | ||
|
|
7fc5e06517 | ||
|
|
704ba04c51 | ||
|
|
8a30b9717e |
@@ -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.17', '1.16' ]
|
||||
name: Go ${{ matrix.go }} build
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Build and test
|
||||
run: |
|
||||
go build ./...
|
||||
go test -race -coverprofile=profile.cov ./...
|
||||
- name: Send coverage
|
||||
uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
path-to-profile: profile.cov
|
||||
flag-name: Go-${{ matrix.go }}
|
||||
parallel: true
|
||||
coverage:
|
||||
needs: go-build
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
parallel-finished: true
|
||||
54
.github/workflows/docker-build.yml
vendored
Normal file
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.17
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ui/yarn.lock
|
||||
- name: Build frontend
|
||||
run: |
|
||||
yarn install --frozen-lockfile --non-interactive
|
||||
yarn run build
|
||||
working-directory: ./ui
|
||||
- name: Test build release
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot
|
||||
- name: Build and publish release
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
args: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
22
.gitignore
vendored
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
|
||||
|
||||
24
.travis.yml
24
.travis.yml
@@ -1,24 +0,0 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- rpm
|
||||
|
||||
env:
|
||||
- DEPLOY_WITH_MAJOR="1.10"
|
||||
|
||||
before_script:
|
||||
- go get github.com/golang/lint/golint
|
||||
- make deps
|
||||
|
||||
go:
|
||||
- "1.10.1"
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
branch: master
|
||||
205
CHANGELOG.md
205
CHANGELOG.md
@@ -4,7 +4,156 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [2.0.0-rc1] - 2018-04-07
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.0.2] - 2022-07-04
|
||||
|
||||
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
|
||||
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
|
||||
details.
|
||||
|
||||
### Changed
|
||||
- arm Docker builds now rely on amd64 frontend build stage
|
||||
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
|
||||
|
||||
|
||||
## [v3.0.1-rc2] - 2022-01-23
|
||||
|
||||
### Added
|
||||
- Builds for arm7 and arm64 platforms
|
||||
|
||||
### Changed
|
||||
- Abandoned git-flow process, the `master` branch renamed to `main`
|
||||
|
||||
|
||||
## [v3.0.1-rc1] - 2022-01-17
|
||||
|
||||
### Fixed
|
||||
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
|
||||
|
||||
### Changed
|
||||
- Update Go dependencies
|
||||
- Update NPM dependencies
|
||||
|
||||
|
||||
## [v3.0.0] - 2021-09-19
|
||||
|
||||
Unchanged from rc4.
|
||||
|
||||
|
||||
## [v3.0.0-rc4] - 2021-08-22
|
||||
|
||||
### Fixed
|
||||
- Various MIME header decoding improvements
|
||||
|
||||
### Changed
|
||||
- Bump Go version to 1.17 (#233)
|
||||
|
||||
|
||||
## v3.0.0-rc3 - 2021-08-01
|
||||
|
||||
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
|
||||
tags for Docker Hub and ghcr.io.
|
||||
|
||||
|
||||
## [v3.0.0-rc2] - 2021-07-31
|
||||
|
||||
### Added
|
||||
- Support for SMTP AUTH (#197, thanks makarchuk)
|
||||
- Dark mode support (#218, thanks nerones)
|
||||
|
||||
### Fixed
|
||||
- Prevent potential click jacking (#190, thanks stuartskelton)
|
||||
- Error on 8 character long SMTP commands (#221)
|
||||
- Allow empty username and password during AUTH (#225)
|
||||
|
||||
|
||||
## [v3.0.0-rc1] - 2020-09-24
|
||||
|
||||
### Added
|
||||
- Refresh button to reload mailbox contents
|
||||
- Improved keyboard (tab) focus highlights
|
||||
|
||||
### Changed
|
||||
- The UI now includes the Open Sans webfont instead of relying on browser/OS
|
||||
fonts
|
||||
|
||||
|
||||
## [v3.0.0-beta3] - 2020-09-04
|
||||
|
||||
### Added
|
||||
- Docker `HEALTHCHECK`
|
||||
- Mouse-out delay to improve pop-up menu navigation
|
||||
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||
|
||||
### Changed
|
||||
- Updated frontend and backend dependencies, Docker image base
|
||||
|
||||
### Fixed
|
||||
- Improved layout on mobile and wide displays
|
||||
- Prevent unexpected input for modal dialogs
|
||||
- Allow empty SMTP `MAIL FROM:<>`
|
||||
|
||||
|
||||
## [v3.0.0-beta2] - 2019-08-17
|
||||
|
||||
### Added
|
||||
- Ability to name mailboxes after domain of email recipient, set via
|
||||
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
|
||||
|
||||
### Changed
|
||||
- Updated JavaScript dependencies.
|
||||
- Updated Go dependencies.
|
||||
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
|
||||
|
||||
### Fixed
|
||||
- URLs to view/download attachments from REST API, #138
|
||||
- Support for late EHLO, #141
|
||||
|
||||
|
||||
## [v3.0.0-beta1] - 2019-03-14
|
||||
|
||||
### Added
|
||||
- `posix-millis` field to REST message and header responses for easier date
|
||||
parsing.
|
||||
|
||||
### Changed
|
||||
- Rewrote the user interface from scratch, it's now an Elm powered single page
|
||||
application.
|
||||
- Moved the Inbucket repository to its own GitHub organization.
|
||||
- Update to enmime v0.5.0
|
||||
|
||||
|
||||
## v2.1.0 - 2018-12-15
|
||||
|
||||
No change from beta1.
|
||||
|
||||
|
||||
## [v2.1.0-beta1] - 2018-10-31
|
||||
|
||||
### Added
|
||||
- Use Go 1.11 modules for reproducible builds.
|
||||
- SMTP TLS support (thanks kingforaday.)
|
||||
- `INBUCKET_WEB_PPROF` configuration option for performance profiling.
|
||||
- Godoc example for the REST API client.
|
||||
|
||||
### Changed
|
||||
- Docker build now uses Go 1.11 and Alpine 3.8
|
||||
|
||||
### Fixed
|
||||
- Render UTF-8 addresses correctly in both REST API and Web UI.
|
||||
- Memory storage now correctly returns the newest message when asked for ID
|
||||
`latest`.
|
||||
|
||||
|
||||
## [v2.0.0] - 2018-05-05
|
||||
|
||||
### Changed
|
||||
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
|
||||
- Disabled color log output on Windows, doesn't work there.
|
||||
|
||||
|
||||
## [v2.0.0-rc1] - 2018-04-07
|
||||
|
||||
### Added
|
||||
- Inbucket is now configured using environment variables instead of a config
|
||||
@@ -73,7 +222,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||
provides a more natural API
|
||||
- Powerful command line REST
|
||||
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
|
||||
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
|
||||
- Allow use of `latest` as a message ID in REST calls
|
||||
|
||||
### Changed
|
||||
@@ -88,9 +237,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Added
|
||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||
- Attachment list to [GET message
|
||||
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
|
||||
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
|
||||
- [Go client for REST
|
||||
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
|
||||
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
|
||||
- Monitor feature: lists messages as they arrive, regardless of their
|
||||
destination mailbox
|
||||
- Make `@inbucket` mailbox prompt configurable
|
||||
@@ -153,31 +302,45 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.2...main
|
||||
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
|
||||
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
|
||||
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
|
||||
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
|
||||
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
|
||||
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
|
||||
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
|
||||
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
|
||||
## Release Checklist
|
||||
|
||||
1. Create release branch: `git flow release start 1.x.0`
|
||||
1. Create a release branch
|
||||
2. Update CHANGELOG.md:
|
||||
- Ensure *Unreleased* section is up to date
|
||||
- Rename *Unreleased* section to release name and date.
|
||||
- Rename *Unreleased* section to release name and date
|
||||
- Add new GitHub `/compare` link
|
||||
- Update previous tag version for *Unreleased*
|
||||
3. Run tests
|
||||
4. Test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release: `git flow release finish`
|
||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
||||
complete
|
||||
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||
6. Push tags and wait for
|
||||
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
|
||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
|
||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
How to Contribute
|
||||
=================
|
||||
# How to Contribute
|
||||
|
||||
Inbucket encourages third-party patches. It's valuable to know how other
|
||||
developers are using the product.
|
||||
|
||||
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
|
||||
|
||||
## Making Changes
|
||||
|
||||
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
||||
you can run `git flow feature start <topic branch name>`.
|
||||
|
||||
Without git-flow, create a topic branch from where you want to base your work:
|
||||
- This is usually the `develop` branch, example command:
|
||||
`git checkout origin/develop -b <topic branch name>`
|
||||
- Only target the `master` branch if the issue is already resolved in
|
||||
`develop`.
|
||||
Inbucket follows the regular GitHub pattern. Create a topic branch from where
|
||||
you want to base your work:
|
||||
|
||||
Once you are on your topic branch:
|
||||
|
||||
1. Make commits of logical units.
|
||||
2. Add unit tests to exercise your changes.
|
||||
3. Run the updated code through `go fmt` and `go vet`.
|
||||
4. Ensure the code builds and tests with the following commands:
|
||||
- `go clean ./...`
|
||||
- `go build ./...`
|
||||
- `go test ./...`
|
||||
3. Run `make` to test, vet and confirm your code is formatted correctly.
|
||||
If you do not have Make installed, please perform these steps manually,
|
||||
otherwise your PR will not pass our checks.
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
Thank you for contributing to Inbucket!
|
||||
|
||||
[git-flow]: https://github.com/nvie/gitflow
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,9 +1,19 @@
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
# Build
|
||||
FROM golang:1.10-alpine as builder
|
||||
RUN apk add --no-cache --virtual .build-deps git make
|
||||
WORKDIR /go/src/github.com/jhillyerd/inbucket
|
||||
### Build frontend
|
||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||
FROM --platform=linux/amd64 node:16 as frontend
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf .parcel-cache dist elm-stuff node_modules
|
||||
RUN yarn install --frozen-lockfile --non-interactive
|
||||
RUN yarn run build
|
||||
|
||||
### Build backend
|
||||
FROM golang:1.17-alpine3.14 as backend
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
RUN make clean deps
|
||||
@@ -11,14 +21,14 @@ RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
# Run in minimal image
|
||||
FROM alpine:3.7
|
||||
ENV SRC /go/src/github.com/jhillyerd/inbucket
|
||||
### Run in minimal image
|
||||
FROM alpine:3.14
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=builder $SRC/inbucket bin
|
||||
COPY --from=backend /build/inbucket bin
|
||||
COPY --from=frontend /build/ui/dist ui
|
||||
COPY etc/docker/defaults/greeting.html defaults
|
||||
COPY ui ui
|
||||
COPY etc/docker/defaults/start-inbucket.sh /
|
||||
|
||||
# Configuration
|
||||
@@ -27,11 +37,15 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||
ENV INBUCKET_WEB_UIDIR=ui
|
||||
ENV INBUCKET_STORAGE_TYPE file
|
||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
|
||||
|
||||
# Ports: SMTP, HTTP, POP3
|
||||
EXPOSE 2500 9000 1100
|
||||
|
||||
|
||||
85
README.md
85
README.md
@@ -1,16 +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/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.
|
||||
@@ -19,48 +24,66 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
|
||||
to contribute code to the project check out [CONTRIBUTING.md].
|
||||
|
||||
|
||||
## Homebrew Tap
|
||||
## Docker
|
||||
|
||||
(currently broken, being tracked in [issue
|
||||
#68](https://github.com/jhillyerd/inbucket/issues/68))
|
||||
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||
`main` branch.
|
||||
|
||||
|
||||
## Building from Source
|
||||
|
||||
You will need a functioning [Go installation][Google Go] for this to work.
|
||||
You will need functioning [Go] and [Node.js] installations for this to work.
|
||||
|
||||
Grab the Inbucket source code and compile the daemon:
|
||||
```sh
|
||||
git clone https://github.com/inbucket/inbucket.git
|
||||
cd inbucket/ui
|
||||
yarn install
|
||||
yarn build
|
||||
cd ..
|
||||
go build ./cmd/inbucket
|
||||
```
|
||||
|
||||
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
|
||||
For more information on building and development flows, check out the
|
||||
[Development Quickstart] page of our wiki.
|
||||
|
||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||
Unix and OS X machines as is. Launch the daemon:
|
||||
### Configure and Launch
|
||||
|
||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
||||
Inbucket reads its configuration from environment variables, but comes with
|
||||
reasonable defaults built-in. It should work on most Unix and OS X machines as
|
||||
is. Launch the daemon:
|
||||
|
||||
```sh
|
||||
./inbucket
|
||||
```
|
||||
|
||||
By default the SMTP server will be listening on localhost port 2500 and
|
||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
|
||||
The Inbucket website has a more complete guide to
|
||||
[installing from source][From Source]
|
||||
See doc/[config.md] for more information on configuring Inbucket, but you will
|
||||
likely find the [Configurator] tool the easiest way to generate a configuration.
|
||||
|
||||
|
||||
## About
|
||||
|
||||
Inbucket is written in [Google Go]
|
||||
Inbucket is written in [Go] and [Elm].
|
||||
|
||||
Inbucket is open source software released under the MIT License. The latest
|
||||
version can be found at https://github.com/jhillyerd/inbucket
|
||||
version can be found at https://github.com/inbucket/inbucket
|
||||
|
||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||
[Google Go]: http://golang.org/
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
||||
[Inbucket Website]: http://www.inbucket.org/
|
||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||
[Configurator]: https://www.inbucket.org/configurator/
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
|
||||
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
|
||||
[Docker Image]: https://www.inbucket.org/binaries/docker.html
|
||||
[Elm]: https://elm-lang.org/
|
||||
[From Source]: https://www.inbucket.org/installation/from-source.html
|
||||
[Go]: https://golang.org/
|
||||
[Go API docs]: https://pkg.go.dev/github.com/inbucket/inbucket/pkg/rest/client
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||
[Inbucket Website]: https://www.inbucket.org/
|
||||
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||
[Node.js]: https://nodejs.org/en/
|
||||
|
||||
@@ -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)
|
||||
webui.SetupRoutes(web.Router)
|
||||
go web.Start(rootCtx)
|
||||
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel.
|
||||
signalLoop:
|
||||
for {
|
||||
@@ -156,6 +167,7 @@ signalLoop:
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish.
|
||||
go timedExit(*pidfile)
|
||||
smtpServer.Drain()
|
||||
@@ -181,7 +193,7 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
|
||||
}
|
||||
close = func() {}
|
||||
var w io.Writer
|
||||
color := true
|
||||
color := runtime.GOOS != "windows"
|
||||
switch logfile {
|
||||
case "stderr":
|
||||
w = os.Stderr
|
||||
|
||||
117
doc/config.md
117
doc/config.md
@@ -21,17 +21,19 @@ variables it supports:
|
||||
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
|
||||
INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option
|
||||
INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support
|
||||
INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support
|
||||
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
|
||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||
INBUCKET_WEB_UIDIR ui User interface dir
|
||||
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
|
||||
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
|
||||
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
|
||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
||||
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
|
||||
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
||||
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
||||
@@ -75,8 +77,14 @@ Prior to the addition of the mailbox naming setting, Inbucket always operated in
|
||||
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||
incorporated into the mailbox name.
|
||||
|
||||
#### `domain` ensures the local-part is removed, such that:
|
||||
|
||||
- `james@inbucket.org` is stored in `inbucket.org`
|
||||
- `matt@inbucket.org` is stored in `inbucket.org`
|
||||
- `matt@noinbucket.com` is stored in `notinbucket.com`
|
||||
|
||||
- Default: `local`
|
||||
- Values: one of `local` or `full`
|
||||
- Values: one of `local` or `full` or `domain`
|
||||
|
||||
|
||||
## SMTP
|
||||
@@ -202,6 +210,36 @@ to the public internet.
|
||||
- Default: `300s`
|
||||
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||
|
||||
### TLS Support Availability
|
||||
|
||||
`INBUCKET_SMTP_TLSENABLED`
|
||||
|
||||
Enable the STARTTLS option for opportunistic TLS support
|
||||
|
||||
- Default: `false`
|
||||
- Values: `true` or `false`
|
||||
|
||||
### TLS Private Key File
|
||||
|
||||
`INBUCKET_SMTP_TLSPRIVKEY`
|
||||
|
||||
Specify the x509 Private key file to be used for TLS negotiation.
|
||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
|
||||
- Default: `cert.key`
|
||||
- Values: filename or path to private key
|
||||
- Example: `server.privkey`
|
||||
|
||||
### TLS Public Certificate File
|
||||
|
||||
`INBUCKET_SMTP_TLSCERT`
|
||||
|
||||
Specify the x509 Certificate file to be used for TLS negotiation.
|
||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
|
||||
- Default: `cert.crt`
|
||||
- Values: filename or path to the certificate key
|
||||
- Example: `server.crt`
|
||||
|
||||
## POP3
|
||||
|
||||
@@ -253,6 +291,24 @@ Inbucket to listen on all available network interfaces.
|
||||
|
||||
- Default: `0.0.0.0:9000`
|
||||
|
||||
### Base Path
|
||||
|
||||
`INBUCKET_WEB_BASEPATH`
|
||||
|
||||
Base path prefix for UI and API URLs. This option is used when you wish to
|
||||
root all Inbucket URLs to a specific path when placing it behind a
|
||||
reverse-proxy.
|
||||
|
||||
For example, setting the base path to `prefix` will move:
|
||||
- the Inbucket status page from `/status` to `/prefix/status`,
|
||||
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
|
||||
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
|
||||
|
||||
*Note:* This setting will not work correctly when running Inbucket via the npm
|
||||
development server.
|
||||
|
||||
- Default: None
|
||||
|
||||
### UI Directory
|
||||
|
||||
`INBUCKET_WEB_UIDIR`
|
||||
@@ -264,7 +320,7 @@ doesn't contain the `ui` directory at startup.
|
||||
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||
static assets from the `static` sub-directory.
|
||||
|
||||
- Default: `ui`
|
||||
- Default: `ui/dist`
|
||||
- Values: Operating system specific path syntax
|
||||
|
||||
### Greeting HTML File
|
||||
@@ -277,39 +333,6 @@ Inbucket installation, as well as link to REST documentation, etc.
|
||||
|
||||
- Default: `ui/greeting.html`
|
||||
|
||||
### Template Caching
|
||||
|
||||
`INBUCKET_WEB_TEMPLATECACHE`
|
||||
|
||||
Tells Inbucket to cache parsed template files. This should be left as default
|
||||
unless you are a developer working on the Inbucket web interface.
|
||||
|
||||
- Default: `true`
|
||||
- Values: `true` or `false`
|
||||
|
||||
### Mailbox Prompt
|
||||
|
||||
`INBUCKET_WEB_MAILBOXPROMPT`
|
||||
|
||||
Text prompt displayed to the right of the mailbox name input field in the web
|
||||
interface. Can be used to nudge your users into typing just the mailbox name
|
||||
instead of an entire email address.
|
||||
|
||||
Set to an empty string to hide the prompt.
|
||||
|
||||
- Default: `@inbucket`
|
||||
|
||||
### Cookie Authentication Key
|
||||
|
||||
`INBUCKET_WEB_COOKIEAUTHKEY`
|
||||
|
||||
Inbucket stores session information in an encrypted browser cookie. Unless
|
||||
specified, Inbucket generates a random key at startup. The only notable data
|
||||
stored in a user session is the list of recently accessed mailboxes.
|
||||
|
||||
- Default: None
|
||||
- Value: Text string, no particular format required
|
||||
|
||||
### Monitor Visible
|
||||
|
||||
`INBUCKET_WEB_MONITORVISIBLE`
|
||||
@@ -344,6 +367,20 @@ them.
|
||||
- Default: `30`
|
||||
- Values: Integer greater than or equal to 0
|
||||
|
||||
### Performance Profiling & Debug Tools
|
||||
|
||||
`INBUCKET_WEB_PPROF`
|
||||
|
||||
If true, Go's pprof package will be installed to the `/debug/pprof` URI. This
|
||||
exposes detailed memory and CPU performance data for debugging Inbucket. If you
|
||||
enable this option, please make sure it is not exposed to the public internet,
|
||||
as its use can significantly impact performance.
|
||||
|
||||
For example usage, see https://golang.org/pkg/net/http/pprof/
|
||||
|
||||
- Default: `false`
|
||||
- Values: `true` or `false`
|
||||
|
||||
|
||||
## Storage
|
||||
|
||||
@@ -373,7 +410,7 @@ Parameters specific to the storage type selected. Formatted as a comma
|
||||
separated list of key:value pairs.
|
||||
|
||||
- Default: None
|
||||
- Examples: `maxkb=10240` or `path=/tmp/inbucket`
|
||||
- Examples: `maxkb:10240` or `path:/tmp/inbucket`
|
||||
|
||||
#### `file` type parameters
|
||||
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
<p>To view email for a particular address, enter the username portion
|
||||
<p>To view messages for a particular address, enter the username portion
|
||||
of the address into the box on the upper right and click <em>View</em>.</p>
|
||||
|
||||
<p>This instance of Inbucket is running inside of a <a
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# description: Launch Inbucket's docker image
|
||||
|
||||
# Docker Image Tag
|
||||
IMAGE="jhillyerd/inbucket"
|
||||
IMAGE="inbucket/inbucket"
|
||||
|
||||
# Ports exposed on host:
|
||||
PORT_HTTP=9000
|
||||
@@ -12,9 +12,9 @@ PORT_POP3=1100
|
||||
|
||||
# Volumes exposed on host:
|
||||
VOL_CONFIG="/tmp/inbucket/config"
|
||||
VOL_DATA="/tmp/inbucket/data"
|
||||
VOL_DATA="/tmp/inbucket/storage"
|
||||
|
||||
set -eo pipefail
|
||||
set -e
|
||||
|
||||
main() {
|
||||
local run_opts=""
|
||||
@@ -39,11 +39,11 @@ main() {
|
||||
done
|
||||
|
||||
docker run $run_opts \
|
||||
-p $PORT_HTTP:10080 \
|
||||
-p $PORT_SMTP:10025 \
|
||||
-p $PORT_POP3:10110 \
|
||||
-v "$VOL_CONFIG:/con/configuration" \
|
||||
-v "$VOL_DATA:/con/data" \
|
||||
-p $PORT_HTTP:9000 \
|
||||
-p $PORT_SMTP:2500 \
|
||||
-p $PORT_POP3:1100 \
|
||||
-v "$VOL_CONFIG:/config" \
|
||||
-v "$VOL_DATA:/storage" \
|
||||
"$IMAGE"
|
||||
}
|
||||
|
||||
|
||||
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,6 +1,8 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
To: %TO_ADDRESS%,
|
||||
=?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?=
|
||||
<fromuser@inbucket.org>
|
||||
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# travis-deploy.sh
|
||||
# description: Trigger goreleaser deployment in correct build scenarios
|
||||
|
||||
set -eo pipefail
|
||||
set -x
|
||||
|
||||
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
|
||||
curl -sL https://git.io/goreleaser | bash
|
||||
fi
|
||||
23
go.mod
Normal file
23
go.mod
Normal file
@@ -0,0 +1,23 @@
|
||||
module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/jhillyerd/enmime v0.9.2
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.17
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
91
go.sum
Normal file
91
go.sum
Normal file
@@ -0,0 +1,91 @@
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
|
||||
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
|
||||
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
|
||||
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
UnknownNaming mbNaming = iota
|
||||
LocalNaming
|
||||
FullNaming
|
||||
DomainNaming
|
||||
)
|
||||
|
||||
// Decode a naming strategy from string.
|
||||
@@ -47,6 +48,8 @@ func (n *mbNaming) Decode(v string) error {
|
||||
*n = LocalNaming
|
||||
case "full":
|
||||
*n = FullNaming
|
||||
case "domain":
|
||||
*n = DomainNaming
|
||||
default:
|
||||
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func (n *mbNaming) Decode(v string) error {
|
||||
// Root contains global configuration, and structs with for specific sub-systems.
|
||||
type Root struct {
|
||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
|
||||
SMTP SMTP
|
||||
POP3 POP3
|
||||
Web Web
|
||||
@@ -76,6 +79,9 @@ type SMTP struct {
|
||||
StoreDomains []string `desc:"Domains to store mail for"`
|
||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||
Debug bool `ignored:"true"`
|
||||
}
|
||||
|
||||
@@ -90,13 +96,12 @@ type POP3 struct {
|
||||
// Web contains the HTTP server configuration.
|
||||
type Web struct {
|
||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
|
||||
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
|
||||
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
|
||||
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
|
||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||
}
|
||||
|
||||
// Storage contains the mail store configuration.
|
||||
|
||||
@@ -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: msg.From.String(),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
}
|
||||
}
|
||||
return web.RenderJSON(w, jmessages)
|
||||
@@ -64,28 +65,30 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||
for i, part := range attachParts {
|
||||
content := part.Content
|
||||
var checksum = md5.Sum(content)
|
||||
// Example URL: http://localhost/serve/mailbox/swaks/0001/attach/0/favicon.png
|
||||
link := "http://" + req.Host + "/serve/mailbox/" + name + "/" + id + "/attach/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName
|
||||
checksum := md5.Sum(content)
|
||||
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||
ContentType: part.ContentType,
|
||||
FileName: part.FileName,
|
||||
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName,
|
||||
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
|
||||
strconv.Itoa(i) + "/" + part.FileName,
|
||||
MD5: hex.EncodeToString(checksum[:]),
|
||||
ContentType: part.ContentType,
|
||||
FileName: part.FileName,
|
||||
DownloadLink: link,
|
||||
ViewLink: link,
|
||||
MD5: hex.EncodeToString(checksum[:]),
|
||||
}
|
||||
}
|
||||
return web.RenderJSON(w,
|
||||
&model.JSONMessageV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: msg.From.String(),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Body: &model.JSONMessageBodyV1{
|
||||
Text: msg.Text(),
|
||||
HTML: msg.HTML(),
|
||||
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://localhost/api/v1"
|
||||
|
||||
// JSON map keys
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
@@ -37,7 +35,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
logbuf := setupWebServer(mm)
|
||||
|
||||
// Test invalid mailbox name
|
||||
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
|
||||
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
||||
expectCode := 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -47,7 +45,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test empty mailbox
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/empty")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -57,7 +55,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test Mailbox error
|
||||
w, err = testRestGet(baseURL + "/mailbox/messageserr")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/messageserr")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -89,7 +87,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -114,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||
@@ -122,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
|
||||
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestRestMessage(t *testing.T) {
|
||||
logbuf := setupWebServer(mm)
|
||||
|
||||
// Test invalid mailbox name
|
||||
w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001")
|
||||
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
|
||||
expectCode := 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -149,7 +149,7 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test requesting a message that does not exist
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/empty/0001")
|
||||
expectCode = 404
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -159,7 +159,7 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test GetMessage error
|
||||
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/messageerr/0001")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -196,12 +196,16 @@ func TestRestMessage(t *testing.T) {
|
||||
"From": []string{"noreply@inbucket.org"},
|
||||
},
|
||||
},
|
||||
Attachments: []*enmime.Part{{
|
||||
FileName: "favicon.png",
|
||||
ContentType: "image/png",
|
||||
}},
|
||||
},
|
||||
)
|
||||
mm.AddMessage("good", msg1)
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good/0001")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good/0001")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -223,6 +227,7 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "subject", "subject 1")
|
||||
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "size", 0)
|
||||
decodedBoolEquals(t, result, "seen", true)
|
||||
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||
@@ -230,6 +235,10 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
||||
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -264,7 +273,7 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||
// Mark one read.
|
||||
w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`)
|
||||
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
||||
expectCode := 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -273,7 +282,7 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
// Get mailbox.
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,183 +1,259 @@
|
||||
package client
|
||||
package client_test
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
func TestClientV1ListMailbox(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
listHandler := &jsonHandler{json: `[
|
||||
{
|
||||
"mailbox": "testbox",
|
||||
"id": "1",
|
||||
"from": "fromuser",
|
||||
"subject": "test subject",
|
||||
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||
"size": 264,
|
||||
"seen": true
|
||||
}
|
||||
]`}
|
||||
|
||||
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||
|
||||
// Method under test.
|
||||
headers, err := c.ListMailbox("testbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_, _ = c.ListMailbox("testbox")
|
||||
if len(headers) != 1 {
|
||||
t.Fatalf("Got %v headers, want 1", len(headers))
|
||||
}
|
||||
h := headers[0]
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
got := h.Mailbox
|
||||
want := "testbox"
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
||||
got = mth.req.URL.String()
|
||||
got = h.ID
|
||||
want = "1"
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
t.Errorf("ID got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = h.From
|
||||
want = "fromuser"
|
||||
if got != want {
|
||||
t.Errorf("From got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = h.Subject
|
||||
want = "test subject"
|
||||
if got != want {
|
||||
t.Errorf("Subject got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
gotTime := h.Date
|
||||
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||
if !wantTime.Equal(gotTime) {
|
||||
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||
}
|
||||
|
||||
gotInt := h.Size
|
||||
wantInt := int64(264)
|
||||
if gotInt != wantInt {
|
||||
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||
}
|
||||
|
||||
wantBool := true
|
||||
gotBool := h.Seen
|
||||
if gotBool != wantBool {
|
||||
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1GetMessage(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
messageHandler := &jsonHandler{json: `{
|
||||
"mailbox": "testbox",
|
||||
"id": "20170107T224128-0000",
|
||||
"from": "fromuser",
|
||||
"subject": "test subject",
|
||||
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||
"size": 264,
|
||||
"seen": true,
|
||||
"body": {
|
||||
"text": "Plain text",
|
||||
"html": "<html>"
|
||||
}
|
||||
}`}
|
||||
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("GET").Handler(messageHandler)
|
||||
|
||||
// Method under test.
|
||||
m, err := c.GetMessage("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_, _ = c.GetMessage("testbox", "20170107T224128-0000")
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
if m == nil {
|
||||
t.Fatalf("message was nil, wanted a value")
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||
got = mth.req.URL.String()
|
||||
got := m.Mailbox
|
||||
want := "testbox"
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = m.ID
|
||||
want = "20170107T224128-0000"
|
||||
if got != want {
|
||||
t.Errorf("ID got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = m.From
|
||||
want = "fromuser"
|
||||
if got != want {
|
||||
t.Errorf("From got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = m.Subject
|
||||
want = "test subject"
|
||||
if got != want {
|
||||
t.Errorf("Subject got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
gotTime := m.Date
|
||||
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||
if !wantTime.Equal(gotTime) {
|
||||
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||
}
|
||||
|
||||
gotInt := m.Size
|
||||
wantInt := int64(264)
|
||||
if gotInt != wantInt {
|
||||
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||
}
|
||||
|
||||
gotBool := m.Seen
|
||||
wantBool := true
|
||||
if gotBool != wantBool {
|
||||
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||
}
|
||||
|
||||
got = m.Body.Text
|
||||
want = "Plain text"
|
||||
if got != want {
|
||||
t.Errorf("Body Text got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = m.Body.HTML
|
||||
want = "<html>"
|
||||
if got != want {
|
||||
t.Errorf("Body HTML got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1MarkSeen(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("PATCH").
|
||||
Handler(handler)
|
||||
|
||||
// Method under test.
|
||||
err := c.MarkSeen("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_ = c.MarkSeen("testbox", "20170107T224128-0000")
|
||||
|
||||
want = "PATCH"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
if !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1GetMessageSource(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{
|
||||
body: "message source",
|
||||
}
|
||||
c.client = mth
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
|
||||
Handler(&jsonHandler{json: `message source`})
|
||||
|
||||
// Method under test
|
||||
// Method under test.
|
||||
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want := "message source"
|
||||
got := source.String()
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000/source"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "message source"
|
||||
got = source.String()
|
||||
if got != want {
|
||||
t.Errorf("Source == %q, want: %q", got, want)
|
||||
t.Errorf("Source got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1DeleteMessage(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("DELETE").
|
||||
Handler(handler)
|
||||
|
||||
// Method under test
|
||||
err = c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||
// Method under test.
|
||||
err := c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
if !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1PurgeMailbox(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/testbox").Methods("DELETE").Handler(handler)
|
||||
|
||||
// Method under test
|
||||
err = c.PurgeMailbox("testbox")
|
||||
// Method under test.
|
||||
err := c.PurgeMailbox("testbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
if !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1MessageHeader(t *testing.T) {
|
||||
var want, got string
|
||||
response := `[
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
listHandler := &jsonHandler{json: `[
|
||||
{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
@@ -187,115 +263,52 @@ func TestClientV1MessageHeader(t *testing.T) {
|
||||
"size":100,
|
||||
"seen":true
|
||||
}
|
||||
]`
|
||||
]`}
|
||||
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{body: response}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
// Method under test.
|
||||
headers, err := c.ListMailbox("testbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if len(headers) != 1 {
|
||||
t.Fatalf("len(headers) == %v, want 1", len(headers))
|
||||
}
|
||||
header := headers[0]
|
||||
|
||||
want = "mailbox1"
|
||||
got = header.Mailbox
|
||||
if got != want {
|
||||
t.Errorf("Mailbox == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "id1"
|
||||
got = header.ID
|
||||
if got != want {
|
||||
t.Errorf("ID == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "from1"
|
||||
got = header.From
|
||||
if got != want {
|
||||
t.Errorf("From == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "subject1"
|
||||
got = header.Subject
|
||||
if got != want {
|
||||
t.Errorf("Subject == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
wantb := true
|
||||
gotb := header.Seen
|
||||
if gotb != wantb {
|
||||
t.Errorf("Seen == %v, want %v", gotb, wantb)
|
||||
}
|
||||
|
||||
// Test MessageHeader.Delete()
|
||||
mth.body = ""
|
||||
// Test MessageHeader.Delete().
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("DELETE").Handler(handler)
|
||||
err = header.Delete()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Test MessageHeader.GetSource()
|
||||
mth.body = "source1"
|
||||
_, err = header.GetSource()
|
||||
// Test MessageHeader.GetSource().
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1/source").Methods("GET").
|
||||
Handler(&jsonHandler{json: `source1`})
|
||||
buf, err := header.GetSource()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want := "source1"
|
||||
got := buf.String()
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
t.Errorf("Got source %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Test MessageHeader.GetMessage()
|
||||
mth.body = `{
|
||||
// Test MessageHeader.GetMessage().
|
||||
messageHandler := &jsonHandler{json: `{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
"from":"from1",
|
||||
"subject":"subject1",
|
||||
"date":"2017-01-01T00:00:00.000-07:00",
|
||||
"size":100
|
||||
}`
|
||||
}`}
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("GET").Handler(messageHandler)
|
||||
message, err := header.GetMessage()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -304,53 +317,45 @@ func TestClientV1MessageHeader(t *testing.T) {
|
||||
t.Fatalf("message was nil, wanted a value")
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Test Message.Delete()
|
||||
mth.body = ""
|
||||
// Test Message.Delete().
|
||||
err = message.Delete()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Test MessageHeader.GetSource()
|
||||
mth.body = "source1"
|
||||
_, err = message.GetSource()
|
||||
// Test Message.GetSource().
|
||||
buf, err = message.GetSource()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want = "source1"
|
||||
got = buf.String()
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
t.Errorf("Got source %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// setup returns a client, router and server for API testing.
|
||||
func setup() (c *client.Client, router *mux.Router, teardown func()) {
|
||||
router = mux.NewRouter()
|
||||
server := httptest.NewServer(router)
|
||||
c, err := client.New(server.URL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c, router, func() {
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// jsonHandler returns the string in json when servicing a request.
|
||||
type jsonHandler struct {
|
||||
json string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
j.called = true
|
||||
w.Write([]byte(j.json))
|
||||
}
|
||||
|
||||
102
pkg/rest/client/example_test.go
Normal file
102
pkg/rest/client/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
// Example demonstrates basic usage for the Inbucket REST client.
|
||||
func Example() {
|
||||
// Setup a fake Inbucket server for this example.
|
||||
baseURL, teardown := exampleSetup()
|
||||
defer teardown()
|
||||
|
||||
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
|
||||
// `localhost:9000`.
|
||||
restClient, err := client.New(baseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Get a slice of message headers for the mailbox named `user1`.
|
||||
headers, err := restClient.ListMailbox("user1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, header := range headers {
|
||||
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
|
||||
}
|
||||
|
||||
// Get the content of the first message.
|
||||
message, err := headers[0].GetMessage()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("\nFrom: %v\n", message.From)
|
||||
fmt.Printf("Text body:\n%v", message.Body.Text)
|
||||
|
||||
// Delete the second message.
|
||||
err = headers[1].Delete()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Output:
|
||||
// ID: 20180107T224128-0000, Subject: First subject
|
||||
// ID: 20180108T121212-0123, Subject: Second subject
|
||||
//
|
||||
// From: admin@inbucket.org
|
||||
// Text body:
|
||||
// This is the plain text body
|
||||
}
|
||||
|
||||
// exampleSetup creates a fake Inbucket server to power Example() below.
|
||||
func exampleSetup() (baseURL string, teardown func()) {
|
||||
router := mux.NewRouter()
|
||||
server := httptest.NewServer(router)
|
||||
|
||||
// Handle ListMailbox request.
|
||||
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`[
|
||||
{
|
||||
"mailbox": "user1",
|
||||
"id": "20180107T224128-0000",
|
||||
"subject": "First subject"
|
||||
},
|
||||
{
|
||||
"mailbox": "user1",
|
||||
"id": "20180108T121212-0123",
|
||||
"subject": "Second subject"
|
||||
}
|
||||
]`))
|
||||
})
|
||||
|
||||
// Handle GetMessage request.
|
||||
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{
|
||||
"mailbox": "user1",
|
||||
"id": "20180107T224128-0000",
|
||||
"from": "admin@inbucket.org",
|
||||
"subject": "First subject",
|
||||
"body": {
|
||||
"text": "This is the plain text body"
|
||||
}
|
||||
}`))
|
||||
})
|
||||
|
||||
// Handle Delete request.
|
||||
router.HandleFunc("/api/v1/mailbox/user1/20180108T121212-0123",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
// Nop.
|
||||
})
|
||||
|
||||
return server.URL, func() {
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
@@ -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,25 +1,25 @@
|
||||
package rest
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
import "github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||
|
||||
// SetupRoutes populates the routes for the REST interface
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
// API v1
|
||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||
r.Path("/v1/mailbox/{name}").Handler(
|
||||
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||
r.Path("/v1/mailbox/{name}").Handler(
|
||||
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}/source").Handler(
|
||||
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
||||
r.Path("/api/v1/monitor/messages").Handler(
|
||||
r.Path("/v1/monitor/messages").Handler(
|
||||
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||
r.Path("/api/v1/monitor/messages/{name}").Handler(
|
||||
r.Path("/v1/monitor/messages/{name}").Handler(
|
||||
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -43,15 +43,14 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
log.SetOutput(buf)
|
||||
|
||||
// Have to reset default mux to prevent duplicate routes
|
||||
http.DefaultServeMux = http.NewServeMux()
|
||||
cfg := &config.Root{
|
||||
Web: config.Web{
|
||||
UIDir: "../ui",
|
||||
},
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
SetupRoutes(web.Router)
|
||||
|
||||
return buf
|
||||
}
|
||||
@@ -80,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
|
||||
t.Errorf("JSON result%s", msg)
|
||||
return
|
||||
}
|
||||
if got, ok := val.(float64); ok {
|
||||
got, ok := val.(float64)
|
||||
if ok {
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
|
||||
path, val, val, int64(got), want, int64(want))
|
||||
}
|
||||
|
||||
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ package smtp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -23,10 +25,27 @@ const (
|
||||
// timeStampFormat to use in Received header.
|
||||
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||
|
||||
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
|
||||
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
|
||||
|
||||
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
|
||||
// `User Name`
|
||||
usernameChallenge = "VXNlciBOYW1lAA=="
|
||||
|
||||
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
|
||||
// `Password`
|
||||
passwordChallenge = "UGFzc3dvcmQA"
|
||||
)
|
||||
|
||||
const (
|
||||
// GREET State: Waiting for HELO
|
||||
GREET State = iota
|
||||
// READY State: Got HELO, waiting for MAIL
|
||||
READY
|
||||
// LOGIN State: Got AUTH LOGIN command, expecting Username
|
||||
LOGIN
|
||||
// PASSWORD State: Got Username, expecting password
|
||||
PASSWORD
|
||||
// MAIL State: Got MAIL, accepting RCPTs
|
||||
MAIL
|
||||
// DATA State: Got DATA, waiting for "."
|
||||
@@ -39,7 +58,7 @@ const (
|
||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||
// (?:) is non-grouping sub-match
|
||||
var fromRegex = regexp.MustCompile(
|
||||
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
||||
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
@@ -58,21 +77,23 @@ func (s State) String() string {
|
||||
}
|
||||
|
||||
var commands = map[string]bool{
|
||||
"HELO": true,
|
||||
"EHLO": true,
|
||||
"MAIL": true,
|
||||
"RCPT": true,
|
||||
"DATA": true,
|
||||
"RSET": true,
|
||||
"SEND": true,
|
||||
"SOML": true,
|
||||
"SAML": true,
|
||||
"VRFY": true,
|
||||
"EXPN": true,
|
||||
"HELP": true,
|
||||
"NOOP": true,
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"HELO": true,
|
||||
"EHLO": true,
|
||||
"MAIL": true,
|
||||
"RCPT": true,
|
||||
"DATA": true,
|
||||
"RSET": true,
|
||||
"SEND": true,
|
||||
"SOML": true,
|
||||
"SAML": true,
|
||||
"VRFY": true,
|
||||
"EXPN": true,
|
||||
"HELP": true,
|
||||
"NOOP": true,
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"STARTTLS": true,
|
||||
"AUTH": true,
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
@@ -89,12 +110,15 @@ type Session struct {
|
||||
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||
logger zerolog.Logger // Session specific logger.
|
||||
debug bool // Print network traffic to stdout.
|
||||
tlsState *tls.ConnectionState
|
||||
text *textproto.Conn
|
||||
}
|
||||
|
||||
// NewSession creates a new Session for the given connection
|
||||
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||
reader := bufio.NewReader(conn)
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
|
||||
return &Session{
|
||||
Server: server,
|
||||
id: id,
|
||||
@@ -105,6 +129,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
|
||||
recipients: make([]*policy.Recipient, 0),
|
||||
logger: logger,
|
||||
debug: server.config.Debug,
|
||||
text: textproto.NewConn(conn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
line, err := ssn.readLine()
|
||||
if err == nil {
|
||||
//Handle LOGIN/PASSWORD states here, because they don't expect a command
|
||||
switch ssn.state {
|
||||
case LOGIN:
|
||||
ssn.loginHandler(line)
|
||||
continue
|
||||
case PASSWORD:
|
||||
ssn.passwordHandler(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||
// Check against valid SMTP commands
|
||||
if cmd == "" {
|
||||
@@ -212,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
break
|
||||
}
|
||||
// not an EOF
|
||||
// Not an EOF
|
||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
@@ -232,6 +267,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
|
||||
// GREET state -> waiting for HELO
|
||||
func (s *Session) greetHandler(cmd string, arg string) {
|
||||
const readyBanner = "Great, let's get this show on the road"
|
||||
switch cmd {
|
||||
case "HELO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
@@ -240,7 +276,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
s.remoteDomain = domain
|
||||
s.send("250 Great, let's get this show on the road")
|
||||
s.send("250 " + readyBanner)
|
||||
s.enterState(READY)
|
||||
case "EHLO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
@@ -249,8 +285,13 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
s.remoteDomain = domain
|
||||
s.send("250-Great, let's get this show on the road")
|
||||
// Features before SIZE per RFC
|
||||
s.send("250-" + readyBanner)
|
||||
s.send("250-8BITMIME")
|
||||
s.send("250-AUTH PLAIN LOGIN")
|
||||
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||
s.send("250-STARTTLS")
|
||||
}
|
||||
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
||||
s.enterState(READY)
|
||||
default:
|
||||
@@ -269,9 +310,72 @@ func parseHelloArgument(arg string) (string, error) {
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func (s *Session) loginHandler(line string) {
|
||||
// Content and length of username is ignored.
|
||||
s.send(fmt.Sprintf("334 %v", passwordChallenge))
|
||||
s.enterState(PASSWORD)
|
||||
}
|
||||
|
||||
func (s *Session) passwordHandler(line string) {
|
||||
// Content and length of password is ignored.
|
||||
s.send("235 Authentication successful")
|
||||
s.enterState(READY)
|
||||
}
|
||||
|
||||
// READY state -> waiting for MAIL
|
||||
// AUTH can change
|
||||
func (s *Session) readyHandler(cmd string, arg string) {
|
||||
if cmd == "MAIL" {
|
||||
if cmd == "STARTTLS" {
|
||||
if !s.Server.config.TLSEnabled {
|
||||
// Invalid command since TLS unconfigured.
|
||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||
s.send("454 TLS unavailable on the server")
|
||||
return
|
||||
}
|
||||
if s.tlsState != nil {
|
||||
// TLS state previously valid.
|
||||
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||
s.send("454 A TLS session already agreed upon.")
|
||||
return
|
||||
}
|
||||
s.logger.Debug().Msg("Initiating TLS context.")
|
||||
|
||||
// Start TLS connection handshake.
|
||||
s.send("220 STARTTLS")
|
||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||
s.conn = tlsConn
|
||||
s.text = textproto.NewConn(s.conn)
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
s.enterState(GREET)
|
||||
} else if cmd == "AUTH" {
|
||||
args := strings.SplitN(arg, " ", 3)
|
||||
authMethod := args[0]
|
||||
switch authMethod {
|
||||
case "PLAIN":
|
||||
{
|
||||
if len(args) != 2 {
|
||||
s.send("500 Bad auth arguments")
|
||||
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
|
||||
return
|
||||
}
|
||||
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
|
||||
s.send("235 2.7.0 Authentication successful")
|
||||
return
|
||||
}
|
||||
case "LOGIN":
|
||||
{
|
||||
s.send(fmt.Sprintf("334 %v", usernameChallenge))
|
||||
s.enterState(LOGIN)
|
||||
return
|
||||
}
|
||||
default:
|
||||
{
|
||||
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if cmd == "MAIL" {
|
||||
// Capture group 1: from address. 2: optional params.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
if m == nil {
|
||||
@@ -280,11 +384,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := policy.ParseEmailAddress(from); err != nil {
|
||||
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||
s.send("501 Bad sender address syntax")
|
||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
}
|
||||
if from == "" {
|
||||
from = "unspecified"
|
||||
}
|
||||
|
||||
// This is where the client may put BODY=8BITMIME, but we already
|
||||
// read the DATA as bytes, so it does not effect our processing.
|
||||
if m[2] != "" {
|
||||
@@ -312,6 +420,11 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
s.logger.Info().Msgf("Mail from: %v", from)
|
||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||
s.enterState(MAIL)
|
||||
} else if cmd == "EHLO" {
|
||||
// Reset session
|
||||
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||
s.reset()
|
||||
s.send("250 Session reset")
|
||||
} else {
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
@@ -360,6 +473,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
||||
}
|
||||
s.enterState(DATA)
|
||||
return
|
||||
case "EHLO":
|
||||
// Reset session
|
||||
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||
s.reset()
|
||||
s.send("250 Session reset")
|
||||
return
|
||||
}
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
@@ -367,57 +486,44 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
||||
// DATA
|
||||
func (s *Session) dataHandler() {
|
||||
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||
msgBuf := &bytes.Buffer{}
|
||||
for {
|
||||
lineBuf, err := s.readByteLine()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
s.send("221 Idle timeout, bye bye")
|
||||
}
|
||||
msgBuf, err := s.readDataBlock()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
s.send("221 Idle timeout, bye bye")
|
||||
}
|
||||
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||
s.enterState(QUIT)
|
||||
return
|
||||
}
|
||||
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
|
||||
// Mail data complete.
|
||||
tstamp := time.Now().Format(timeStampFormat)
|
||||
for _, recip := range s.recipients {
|
||||
if recip.ShouldStore() {
|
||||
// Generate Received header.
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
expReceivedTotal.Add(1)
|
||||
}
|
||||
s.send("250 Mail accepted for delivery")
|
||||
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
// RFC: remove leading periods from DATA.
|
||||
if len(lineBuf) > 0 && lineBuf[0] == '.' {
|
||||
lineBuf = lineBuf[1:]
|
||||
}
|
||||
msgBuf.Write(lineBuf)
|
||||
if msgBuf.Len() > s.config.MaxMessageBytes {
|
||||
s.send("552 Maximum message size exceeded")
|
||||
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||
s.enterState(QUIT)
|
||||
return
|
||||
}
|
||||
mailData := bytes.NewBuffer(msgBuf)
|
||||
|
||||
// Mail data complete.
|
||||
tstamp := time.Now().Format(timeStampFormat)
|
||||
for _, recip := range s.recipients {
|
||||
if recip.ShouldStore() {
|
||||
// Generate Received header.
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
expReceivedTotal.Add(1)
|
||||
}
|
||||
s.send("250 Mail accepted for delivery")
|
||||
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) enterState(state State) {
|
||||
@@ -440,7 +546,7 @@ func (s *Session) send(msg string) {
|
||||
s.sendError = err
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
|
||||
if err := s.text.PrintfLine("%s", msg); err != nil {
|
||||
s.sendError = err
|
||||
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||
return
|
||||
@@ -450,24 +556,27 @@ func (s *Session) send(msg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// readByteLine reads a line of input, returns byte slice.
|
||||
func (s *Session) readByteLine() ([]byte, error) {
|
||||
// readDataBlock reads message DATA until `.` using the textproto pkg.
|
||||
func (s *Session) readDataBlock() ([]byte, error) {
|
||||
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := s.reader.ReadBytes('\n')
|
||||
if err == nil && s.debug {
|
||||
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
|
||||
b, err := s.text.ReadDotBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.debug {
|
||||
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
// Reads a line of input
|
||||
// readLine reads a line of input respecting deadlines.
|
||||
func (s *Session) readLine() (line string, err error) {
|
||||
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err = s.reader.ReadString('\n')
|
||||
line, err = s.text.ReadLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -479,28 +588,28 @@ func (s *Session) readLine() (line string, err error) {
|
||||
|
||||
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
l := len(line)
|
||||
|
||||
// Find length of command or entire line.
|
||||
hasArg := true
|
||||
l := strings.IndexByte(line, ' ')
|
||||
if l == -1 {
|
||||
hasArg = false
|
||||
l = len(line)
|
||||
}
|
||||
|
||||
switch {
|
||||
case l == 0:
|
||||
return "", "", true
|
||||
case l < 4:
|
||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||
return "", "", false
|
||||
case l == 4:
|
||||
return strings.ToUpper(line), "", true
|
||||
case l == 5:
|
||||
// Too long to be only command, too short to have args
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
}
|
||||
// If we made it here, command is long enough to have args
|
||||
if line[4] != ' ' {
|
||||
// There wasn't a space after the command?
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
|
||||
if hasArg {
|
||||
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
|
||||
}
|
||||
// I'm not sure if we should trim the args or not, but we will for now
|
||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||
|
||||
return strings.ToUpper(line), "", true
|
||||
}
|
||||
|
||||
// parseArgs takes the arguments proceeding a command and files them
|
||||
@@ -513,7 +622,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||
pm := re.FindAllStringSubmatch(arg, -1)
|
||||
if pm == nil {
|
||||
s.logger.Warn().Msgf("Failed to parse arg string: %q")
|
||||
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
||||
return nil, false
|
||||
}
|
||||
for _, m := range pm {
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -56,6 +56,9 @@ func TestGreetState(t *testing.T) {
|
||||
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Valid EHLOs
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
||||
@@ -70,6 +73,82 @@ func TestGreetState(t *testing.T) {
|
||||
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestEmptyEnvelope(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some empty envelope without blanks
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test out some empty envelope with blanks
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM: <>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test AUTH
|
||||
func TestAuth(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
|
||||
// PLAIN AUTH
|
||||
script := []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"AUTH PLAIN aW5idWNrZXQ6cGFzc3dvcmQK", 235},
|
||||
{"RSET", 250},
|
||||
{"AUTH GSSAPI aW5idWNrZXQ6cGFzc3dvcmQK", 500},
|
||||
{"RSET", 250},
|
||||
{"AUTH PLAIN", 500},
|
||||
{"RSET", 250},
|
||||
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// LOGIN AUTH
|
||||
script = []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"AUTH LOGIN", 334}, // Test with user/pass present.
|
||||
{"username", 334},
|
||||
{"password", 235},
|
||||
{"RSET", 250},
|
||||
{"AUTH LOGIN", 334}, // Test with empty user/pass.
|
||||
{"", 334},
|
||||
{"", 235},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -130,6 +209,15 @@ func TestReadyState(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test Start TLS parsing.
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"STARTTLS", 454}, // TLS unconfigured.
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
@@ -206,6 +294,19 @@ func TestMailState(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test late EHLO, similar to RSET
|
||||
script = []scriptStep{
|
||||
{"EHLO localhost", 250},
|
||||
{"EHLO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
{"RCPT TO:<u1@gmail.com>", 250},
|
||||
{"EHLO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test RSET
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
|
||||
@@ -3,15 +3,16 @@ package smtp
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"expvar"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -63,6 +64,7 @@ type Server struct {
|
||||
manager message.Manager // Used to deliver messages.
|
||||
listener net.Listener // Incoming network connections.
|
||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
// NewServer creates a new Server instance with the specificed config.
|
||||
@@ -72,12 +74,28 @@ func NewServer(
|
||||
manager message.Manager,
|
||||
apolicy *policy.Addressing,
|
||||
) *Server {
|
||||
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||
tlsConfig := &tls.Config{}
|
||||
if smtpConfig.TLSEnabled {
|
||||
var err error
|
||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
|
||||
if err != nil {
|
||||
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||
slog.Error().Msg("Disabling STARTTLS support")
|
||||
smtpConfig.TLSEnabled = false
|
||||
} else {
|
||||
slog.Debug().Msg("STARTTLS feature available")
|
||||
}
|
||||
}
|
||||
|
||||
return &Server{
|
||||
config: smtpConfig,
|
||||
globalShutdown: globalShutdown,
|
||||
manager: manager,
|
||||
addrPolicy: apolicy,
|
||||
wg: new(sync.WaitGroup),
|
||||
tlsConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,60 +3,20 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"stringsJoin": strings.Join,
|
||||
"textToHtml": TextToHTML,
|
||||
}
|
||||
|
||||
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||
|
||||
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
|
||||
// otherwise Mon Jan 2, 2006
|
||||
func FriendlyTime(t time.Time) template.HTML {
|
||||
ty, tm, td := t.Date()
|
||||
ny, nm, nd := time.Now().Date()
|
||||
if (ty == ny) && (tm == nm) && (td == nd) {
|
||||
return template.HTML(t.Format("03:04:05 PM"))
|
||||
}
|
||||
return template.HTML(t.Format("Mon Jan 2, 2006"))
|
||||
}
|
||||
|
||||
// Reverse routing function (shared with templates)
|
||||
func Reverse(name string, things ...interface{}) string {
|
||||
// Convert the things to strings
|
||||
strs := make([]string, len(things))
|
||||
for i, th := range things {
|
||||
strs[i] = fmt.Sprint(th)
|
||||
}
|
||||
// Grab the route
|
||||
u, err := Router.Get(name).URL(strs...)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("name", name).Err(err).
|
||||
Msg("Failed to reverse route")
|
||||
return "/ROUTE-ERROR"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func TextToHTML(text string) template.HTML {
|
||||
func TextToHTML(text string) string {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(replacer.Replace(text))
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
|
||||
@@ -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,29 +3,25 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
const (
|
||||
staticDir = "static"
|
||||
templateDir = "templates"
|
||||
)
|
||||
|
||||
var (
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
@@ -38,7 +34,6 @@ var (
|
||||
rootConfig *config.Root
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
globalShutdown chan bool
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
@@ -50,7 +45,7 @@ func init() {
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method
|
||||
// Initialize sets up things for unit tests or the Start() method.
|
||||
func Initialize(
|
||||
conf *config.Root,
|
||||
shutdownChan chan bool,
|
||||
@@ -60,35 +55,76 @@ func Initialize(
|
||||
rootConfig = conf
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
// NewContext() will use this DataStore for the web handlers.
|
||||
msgHub = mh
|
||||
manager = mm
|
||||
|
||||
// Content Paths
|
||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
||||
// Redirect requests to / if there is a base path configured.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
redirectBase := prefix("/")
|
||||
if redirectBase != "/" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||
Msg("Base path configured")
|
||||
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||
}
|
||||
|
||||
// Dynamic paths.
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(staticPath))))
|
||||
http.Handle("/", Router)
|
||||
|
||||
// Session cookie setup
|
||||
if conf.Web.CookieAuthKey == "" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
||||
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||
}
|
||||
|
||||
// Static paths.
|
||||
Router.PathPrefix(prefix("/static")).Handler(
|
||||
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||
Router.Path(prefix("/favicon.png")).Handler(
|
||||
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||
|
||||
// Parse index.html template, allowing for configuration to be passed to the SPA.
|
||||
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
|
||||
indexTmpl, err := template.ParseFiles(indexPath)
|
||||
if err != nil {
|
||||
msg := "Failed to parse HTML template"
|
||||
cwd, _ := os.Getwd()
|
||||
log.Error().
|
||||
Str("module", "web").
|
||||
Str("phase", "startup").
|
||||
Str("path", indexPath).
|
||||
Str("cwd", cwd).
|
||||
Err(err).
|
||||
Msg(msg)
|
||||
// Create a dummy template to allow tests to pass.
|
||||
indexTmpl, _ = template.New("index.html").Parse(msg)
|
||||
}
|
||||
|
||||
// SPA managed paths.
|
||||
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||
Router.Path(prefix("/")).Handler(spaHandler)
|
||||
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||
|
||||
// Error handlers.
|
||||
Router.NotFoundHandler = noMatchHandler(
|
||||
http.StatusNotFound, "No route matches URI path")
|
||||
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
server = &http.Server{
|
||||
Addr: rootConfig.Web.Addr,
|
||||
Handler: nil,
|
||||
Handler: requestLoggingWrapper(Router),
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
@@ -122,6 +158,23 @@ func Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||
o := &jsonAppConfig{
|
||||
BasePath: webConfig.BasePath,
|
||||
MonitorVisible: webConfig.MonitorVisible,
|
||||
}
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||
Msg("Failed to convert app-config to JSON")
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: "app-config",
|
||||
Value: url.PathEscape(string(b)),
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
@@ -138,29 +191,6 @@ func serve(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error handling request")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -40,10 +40,11 @@ func countGenerator(c chan int) {
|
||||
// Store implements DataStore aand is the root of the mail storage
|
||||
// hiearchy. It provides access to Mailbox objects
|
||||
type Store struct {
|
||||
hashLock storage.HashLock
|
||||
path string
|
||||
mailPath string
|
||||
messageCap int
|
||||
hashLock storage.HashLock
|
||||
path string
|
||||
mailPath string
|
||||
messageCap int
|
||||
bufReaderPool sync.Pool
|
||||
}
|
||||
|
||||
// New creates a new DataStore object using the specified path
|
||||
@@ -60,7 +61,16 @@ func New(cfg config.Storage) (storage.Store, error) {
|
||||
Msg("Error creating dir")
|
||||
}
|
||||
}
|
||||
return &Store{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}, nil
|
||||
return &Store{
|
||||
path: path,
|
||||
mailPath: mailPath,
|
||||
messageCap: cfg.MailboxMsgCap,
|
||||
bufReaderPool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
return bufio.NewReader(nil)
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddMessage adds a message to the specified mailbox.
|
||||
@@ -179,41 +189,33 @@ func (fs *Store) PurgeMessages(mailbox string) error {
|
||||
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||
// continues to return true.
|
||||
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||
infos1, err := ioutil.ReadDir(fs.mailPath)
|
||||
names1, err := readDirNames(fs.mailPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 1 directories
|
||||
for _, inf1 := range infos1 {
|
||||
if inf1.IsDir() {
|
||||
l1 := inf1.Name()
|
||||
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
|
||||
for _, name1 := range names1 {
|
||||
names2, err := readDirNames(fs.mailPath, name1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, name2 := range names2 {
|
||||
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, inf2 := range infos2 {
|
||||
if inf2.IsDir() {
|
||||
l2 := inf2.Name()
|
||||
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, inf3 := range infos3 {
|
||||
if inf3.IsDir() {
|
||||
mb := fs.mboxFromHash(inf3.Name())
|
||||
mb.RLock()
|
||||
msgs, err := mb.getMessages()
|
||||
mb.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f(msgs) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, name3 := range names3 {
|
||||
mb := fs.mboxFromHash(name3)
|
||||
mb.RLock()
|
||||
msgs, err := mb.getMessages()
|
||||
mb.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f(msgs) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +255,18 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
|
||||
}
|
||||
}
|
||||
|
||||
// getPooledReader pulls a buffered reader from the fs.bufReaderPool.
|
||||
func (fs *Store) getPooledReader(r io.Reader) *bufio.Reader {
|
||||
br := fs.bufReaderPool.Get().(*bufio.Reader)
|
||||
br.Reset(r)
|
||||
return br
|
||||
}
|
||||
|
||||
// putPooledReader returns a buffered reader to the fs.bufReaderPool.
|
||||
func (fs *Store) putPooledReader(br *bufio.Reader) {
|
||||
fs.bufReaderPool.Put(br)
|
||||
}
|
||||
|
||||
// generatePrefix converts a Time object into the ISO style format we use
|
||||
// as a prefix for message files. Note: It is used directly by unit
|
||||
// tests.
|
||||
@@ -261,7 +275,16 @@ func generatePrefix(date time.Time) string {
|
||||
}
|
||||
|
||||
// generateId adds a 4-digit unique number onto the end of the string
|
||||
// returned by generatePrefix()
|
||||
// returned by generatePrefix().
|
||||
func generateID(date time.Time) string {
|
||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||
}
|
||||
|
||||
// readDirNames returns a slice of filenames in the specified directory or an error.
|
||||
func readDirNames(elem ...string) ([]string, error) {
|
||||
f, err := os.Open(filepath.Join(elem...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Readdirnames(0)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -120,7 +120,9 @@ func (mb *mbox) readIndex() error {
|
||||
}
|
||||
}()
|
||||
// Decode gob data
|
||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
||||
br := mb.store.getPooledReader(file)
|
||||
defer mb.store.putPooledReader(br)
|
||||
dec := gob.NewDecoder(br)
|
||||
name := ""
|
||||
if err = dec.Decode(&name); err != nil {
|
||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
|
||||
@@ -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.
|
||||
@@ -92,6 +92,17 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
||||
|
||||
// GetMessage gets a mesage.
|
||||
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
|
||||
if id == "latest" {
|
||||
ms, err := s.GetMessages(mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := len(ms)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return ms[count-1], nil
|
||||
}
|
||||
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||
var ok bool
|
||||
m, ok = mb.messages[id]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -19,13 +19,28 @@ func HashMailboxName(mailbox string) string {
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// StringAddressList converts a list of addresses to a list of strings
|
||||
// StringAddress converts an Address to a UTF-8 string.
|
||||
func StringAddress(a *mail.Address) string {
|
||||
b := &strings.Builder{}
|
||||
if a != nil {
|
||||
if a.Name != "" {
|
||||
b.WriteString(a.Name)
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
if a.Address != "" {
|
||||
b.WriteRune('<')
|
||||
b.WriteString(a.Address)
|
||||
b.WriteRune('>')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// StringAddressList converts a list of addresses to a list of UTF-8 strings.
|
||||
func StringAddressList(addrs []*mail.Address) []string {
|
||||
s := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
if a != nil {
|
||||
s[i] = a.String()
|
||||
}
|
||||
s[i] = StringAddress(a)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -46,3 +61,16 @@ func SliceToLower(slice []string) {
|
||||
slice[i] = strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
|
||||
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
|
||||
// The returned prefixer expects all provided paths to start with /.
|
||||
func MakePathPrefixer(prefix string) func(string) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix != "" {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
|
||||
return func(path string) string {
|
||||
return prefix + path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package stringutil_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
func TestHashMailboxName(t *testing.T) {
|
||||
@@ -17,10 +18,14 @@ func TestHashMailboxName(t *testing.T) {
|
||||
|
||||
func TestStringAddressList(t *testing.T) {
|
||||
input := []*mail.Address{
|
||||
{Name: "Fred B. Fish", Address: "fred@fish.org"},
|
||||
{Name: "Fred ß. Fish", Address: "fred@fish.org"},
|
||||
{Name: "User", Address: "user@domain.org"},
|
||||
{Address: "a@b.com"},
|
||||
}
|
||||
want := []string{`"Fred B. Fish" <fred@fish.org>`, `"User" <user@domain.org>`}
|
||||
want := []string{
|
||||
`Fred ß. Fish <fred@fish.org>`,
|
||||
`User <user@domain.org>`,
|
||||
`<a@b.com>`}
|
||||
output := stringutil.StringAddressList(input)
|
||||
if len(output) != len(want) {
|
||||
t.Fatalf("Got %v strings, want: %v", len(output), len(want))
|
||||
@@ -31,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakePathPrefixer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
prefix, path, want string
|
||||
}{
|
||||
{prefix: "", path: "", want: ""},
|
||||
{prefix: "", path: "relative", want: "relative"},
|
||||
{prefix: "", path: "/qualified", want: "/qualified"},
|
||||
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
|
||||
{prefix: "pfx", path: "", want: "/pfx"},
|
||||
{prefix: "pfx", path: "/", want: "/pfx/"},
|
||||
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "/pfx/", path: "", want: "/pfx"},
|
||||
{prefix: "/pfx/", path: "/", want: "/pfx/"},
|
||||
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "a/b/c", path: "", want: "/a/b/c"},
|
||||
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
|
||||
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
|
||||
prefixer := stringutil.MakePathPrefixer(tc.prefix)
|
||||
got := prefixer(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
205
pkg/test/integration_test.go
Normal file
205
pkg/test/integration_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
smtpclient "net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/jhillyerd/goldiff"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
restBaseURL = "http://127.0.0.1:9000/"
|
||||
smtpHost = "127.0.0.1:2500"
|
||||
)
|
||||
|
||||
func TestSuite(t *testing.T) {
|
||||
stopServer, err := startServer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopServer()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
test func(*testing.T)
|
||||
}{
|
||||
{"basic", testBasic},
|
||||
{"fullname", testFullname},
|
||||
{"encodedHeader", testEncodedHeader},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, tc.test)
|
||||
}
|
||||
}
|
||||
|
||||
func testBasic(t *testing.T) {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("basic.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "basic.golden")
|
||||
}
|
||||
|
||||
func testFullname(t *testing.T) {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("fullname.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "fullname.golden")
|
||||
}
|
||||
|
||||
func testEncodedHeader(t *testing.T) {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("encodedheader.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "encodedheader.golden")
|
||||
}
|
||||
|
||||
func formatMessage(m *client.Message) []byte {
|
||||
b := &bytes.Buffer{}
|
||||
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
|
||||
fmt.Fprintf(b, "From: %v\n", m.From)
|
||||
fmt.Fprintf(b, "To: %v\n", m.To)
|
||||
fmt.Fprintf(b, "Subject: %v\n", m.Subject)
|
||||
fmt.Fprintf(b, "Size: %v\n", m.Size)
|
||||
fmt.Fprintf(b, "\nBODY TEXT:\n%v\n", m.Body.Text)
|
||||
fmt.Fprintf(b, "\nBODY HTML:\n%v\n", m.Body.HTML)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func startServer() (func(), error) {
|
||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||
storage.Constructors["memory"] = mem.New
|
||||
os.Clearenv()
|
||||
conf, err := config.Process()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
shutdownChan := make(chan bool)
|
||||
store, err := storage.FromConfig(conf.Storage)
|
||||
if err != nil {
|
||||
rootCancel()
|
||||
return nil, err
|
||||
}
|
||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
// Start HTTP server.
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// TODO Implmement an elegant way to determine server readiness.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
return func() {
|
||||
// Shut everything down.
|
||||
close(shutdownChan)
|
||||
rootCancel()
|
||||
smtpServer.Drain()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readTestData(path ...string) []byte {
|
||||
// Prefix path with testdata.
|
||||
p := append([]string{"testdata"}, path...)
|
||||
f, err := os.Open(filepath.Join(p...))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -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.
|
||||
@@ -27,6 +27,7 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
|
||||
{"metadata", testMetadata, config.Storage{}},
|
||||
{"content", testContent, config.Storage{}},
|
||||
{"delivery order", testDeliveryOrder, config.Storage{}},
|
||||
{"latest", testLatest, config.Storage{}},
|
||||
{"naming", testNaming, config.Storage{}},
|
||||
{"size", testSize, config.Storage{}},
|
||||
{"seen", testSeen, config.Storage{}},
|
||||
@@ -192,6 +193,29 @@ func testDeliveryOrder(t *testing.T, store storage.Store) {
|
||||
}
|
||||
}
|
||||
|
||||
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
|
||||
// the last message sent.
|
||||
func testLatest(t *testing.T, store storage.Store) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
}
|
||||
// Confirm latest.
|
||||
latest, err := store.GetMessage(mailbox, "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
|
||||
}
|
||||
got := latest.Subject()
|
||||
want := "echo"
|
||||
if got != want {
|
||||
t.Errorf("Got subject %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// testNaming ensures the store does not enforce local part mailbox naming.
|
||||
func testNaming(t *testing.T, store storage.Store) {
|
||||
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
||||
@@ -199,7 +223,7 @@ func testNaming(t *testing.T, store storage.Store) {
|
||||
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
||||
}
|
||||
|
||||
// testSize verifies message contnet size metadata values.
|
||||
// testSize verifies message content size metadata values.
|
||||
func testSize(t *testing.T, store storage.Store) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"a", "br", "much longer than the others"}
|
||||
|
||||
12
pkg/test/testdata/basic.golden
vendored
Normal file
12
pkg/test/testdata/basic.golden
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Mailbox: recipient
|
||||
From: <fromuser@inbucket.org>
|
||||
To: [<recipient@inbucket.org>]
|
||||
Subject: basic subject
|
||||
Size: 217
|
||||
|
||||
BODY TEXT:
|
||||
Basic message.
|
||||
|
||||
|
||||
BODY HTML:
|
||||
|
||||
5
pkg/test/testdata/basic.txt
vendored
Normal file
5
pkg/test/testdata/basic.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
From: fromuser@inbucket.org
|
||||
To: recipient@inbucket.org
|
||||
Subject: basic subject
|
||||
|
||||
Basic message.
|
||||
12
pkg/test/testdata/encodedheader.golden
vendored
Normal file
12
pkg/test/testdata/encodedheader.golden
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Mailbox: recipient
|
||||
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
||||
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
||||
Subject: Test of ȇɲʢȯȡɪɴʛ
|
||||
Size: 351
|
||||
|
||||
BODY TEXT:
|
||||
Basic message.
|
||||
|
||||
|
||||
BODY HTML:
|
||||
|
||||
5
pkg/test/testdata/encodedheader.txt
vendored
Normal file
5
pkg/test/testdata/encodedheader.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?= <fromuser@inbucket.org>
|
||||
To: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||
Subject: =?utf-8?b?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
|
||||
Basic message.
|
||||
12
pkg/test/testdata/fullname.golden
vendored
Normal file
12
pkg/test/testdata/fullname.golden
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Mailbox: recipient
|
||||
From: From User <fromuser@inbucket.org>
|
||||
To: [Rec I. Pient <recipient@inbucket.org>]
|
||||
Subject: basic subject
|
||||
Size: 246
|
||||
|
||||
BODY TEXT:
|
||||
Basic message.
|
||||
|
||||
|
||||
BODY HTML:
|
||||
|
||||
5
pkg/test/testdata/fullname.txt
vendored
Normal file
5
pkg/test/testdata/fullname.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
From: From User <fromuser@inbucket.org>
|
||||
To: "Rec I. Pient" <recipient@inbucket.org>
|
||||
Subject: basic subject
|
||||
|
||||
Basic message.
|
||||
@@ -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,9 +1,11 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
<p>To view email for a particular address, enter the username portion
|
||||
<p>To view messages for a particular address, enter the username portion
|
||||
of the address into the box on the upper right and click <em>View</em>.</p>
|
||||
|
||||
<p>This message can be customized by editing greeting.html. Change the
|
||||
configuration option <code>greeting.file</code> if you'd like to move it
|
||||
outside of the Inbucket installation directory.</p>
|
||||
<p>This message can be customized by editing <code>ui/greeting.html</code>.
|
||||
Set the <code>INBUCKET_WEB_GREETINGFILE</code> environment variable if you'd
|
||||
like to move the file outside of the Inbucket installation directory.</p>
|
||||
|
||||
32
ui/package.json
Normal file
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user