mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-11-06 01:21:22 +00:00
Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87888e9dbf | |||
| e84d21cb28 | |||
| 5a886813c3 | |||
| 95281566f6 | |||
| 7044567d64 | |||
| 82ddf2141c | |||
| b554c7db83 | |||
| 36095a2cdf | |||
| e1b8996412 | |||
| 71d3e8df3b | |||
| 2da7ad61cd | |||
| eaae1a1e44 | |||
| a55da8b7d1 | |||
| 561ed93451 | |||
| ef12d02b83 | |||
| de617b6a73 | |||
| 69b6225554 | |||
| 5adef42df7 | |||
| d11ae3710c | |||
| 5d18d79539 | |||
| b38b2e9760 | |||
| 75b7c69b5c | |||
| 239426692e | |||
| 17b054b5a1 | |||
| 7f91c3e9cb | |||
| 55addbb556 | |||
| 8fd5cdfc86 | |||
| e74efbaa77 | |||
| b383fbf9ab | |||
| c9912bc2bb | |||
| f0d457b8f5 | |||
| 3bf4b5c39b | |||
| 37806f222d | |||
| f5899c293c | |||
| cd9c3d61ee | |||
| 37d314fd2e | |||
| 28b0557865 | |||
| 997cb55847 | |||
| 61454a0c9c | |||
| e875a4c382 | |||
| bc6548b6f3 | |||
| 911a6c8d78 | |||
| 547a12ffca | |||
| c8d22ac802 | |||
| 9dbffa88de | |||
| eae4926b23 | |||
| 29d1ed1e7f | |||
| 1f1a8b4192 | |||
| 344c3ffb21 | |||
| 87018ed42d | |||
| 1650a5b375 | |||
| 3f7adbfb22 | |||
| 03cc31fb70 | |||
| a10a6244c9 | |||
| 9185423022 | |||
| 9aaca449f8 | |||
| f39395bd7f | |||
| 2c68128d5d | |||
| 06d4120682 | |||
| 58bcd4f557 | |||
| e91e8d5aee | |||
| 5322462899 | |||
| 5def9ed183 | |||
| 357589d90e | |||
| b664bcfc4c | |||
| ffd13e2ee7 | |||
| 747775b8f2 | |||
| 2c0d942c76 | |||
| e7263439d5 | |||
| cb6f99c487 | |||
| 04fb58e15e | |||
| f11ad55474 | |||
| 26939f2bf6 | |||
| 05a3b1742a | |||
| 867d5f5d7f | |||
| 8e34a21dc6 | |||
| 8869acef0b | |||
| 752d5c9668 | |||
| 091e26c467 | |||
| 6593a36b48 | |||
| 68ef2d9873 | |||
| ab988caf6b | |||
| fa62220d98 | |||
| 1ecf424975 | |||
| 3342938dd4 | |||
| 6be1655723 | |||
| 1465e6fb49 | |||
| 21991cbfc7 | |||
| 7138a97935 | |||
| beee68fc5d | |||
| 9e2af71743 | |||
| a2c4292fc1 | |||
| 2016142747 | |||
| 4f9f961cac | |||
| bf8536abb3 | |||
| 985f2702f2 | |||
| 11f3879442 | |||
| 8562c55c98 | |||
| e3066bb535 | |||
| 35ab31efbc | |||
| 81edf40996 | |||
| c64e7a6a6c | |||
| 4bd64563f2 | |||
| 66dec49a49 | |||
| 649e3743e0 | |||
| c096f018d6 | |||
| 261bbef426 | |||
| 3c5960aba0 | |||
| 7f430f2bde | |||
| c480fcb341 | |||
| e74f5e5116 | |||
| 6ce045ddb7 | |||
| 9b03c311db | |||
| ebd25a60e1 | |||
| 7c87649579 | |||
| e56365b9a0 | |||
| 698b0406c8 | |||
| 361bbec293 | |||
| 407ae87a3b | |||
| 4648d8e593 | |||
| 5c5b0f819b | |||
| 8adfd82232 | |||
| 2162a4caaa | |||
| cf4c5a29bb | |||
| 6598b09114 | |||
| ce5bfddaa5 | |||
| 2934d799ef | |||
| 8a07a24828 | |||
| 2408ace6c2 | |||
| 1a5db5b5f8 | |||
| f712f5b0f3 | |||
| f0520b88c5 | |||
| 5a0c4778cb | |||
| 289b38f016 | |||
| 316a732e7f | |||
| f0bc5741f3 | |||
| 046de42774 | |||
| 860045715c | |||
| 001e9fec58 | |||
| 2e0b7cc097 | |||
| b0bbf2e9f5 | |||
| 3372ade61b | |||
| 62dd540be5 | |||
| 65a6ab2b4f | |||
| 9e1da20782 | |||
| 930801f6da | |||
| 4fc8d229eb | |||
| e8e506f870 | |||
| 8a3d291ff3 | |||
| 107b649738 | |||
| c91a3ecd41 | |||
| 2c74268014 | |||
| da63e4d77a | |||
| 4a90b37815 | |||
| cabbdacb89 | |||
| baad19e838 | |||
| c520af4983 | |||
| c312909112 | |||
| 083b65c9bc | |||
| 59ae2112f7 | |||
| 1a45179e31 | |||
| 2b857245f7 | |||
| 9573504725 | |||
| c21066752f | |||
| 66c95baf05 | |||
| 22a7789b7b | |||
| d2da53cc0f | |||
| bfac9a0cc2 | |||
| a64429ae61 | |||
| 2436f2e3de | |||
| fc76ce74cb | |||
| eef4bbdb01 | |||
| 201987f6a8 | |||
| 45d9d2af39 | |||
| 12802e93cb | |||
| 0956a13618 | |||
| de4bb991dd | |||
| 14f0895ae7 | |||
| 8bb01570ef | |||
| 3a1c757d04 | |||
| d8474d56e5 | |||
| eef45a4473 | |||
| 91d19308fe | |||
| e359c0b030 | |||
| a73ffeabd3 | |||
| 0b3f4eab75 | |||
| 7ea4798e77 | |||
| 070de88bba | |||
| 383386d5fb | |||
| a3e2c5247e | |||
| 702f9ef48e | |||
| ac4501ba35 | |||
| c78656b400 | |||
| b6a6cc6708 |
@@ -6,3 +6,8 @@ inbucket
|
|||||||
inbucket.exe
|
inbucket.exe
|
||||||
swaks-tests
|
swaks-tests
|
||||||
target
|
target
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
ui/dist
|
||||||
|
ui/elm-stuff
|
||||||
|
ui/node_modules
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Build and Test
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
go-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go: [ '1.20', '1.19' ]
|
||||||
|
name: Go ${{ matrix.go }} build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
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
|
||||||
@@ -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@v3
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
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@v2
|
||||||
|
with:
|
||||||
|
platforms: all
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
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@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
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 }}
|
||||||
@@ -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@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: 1.19
|
||||||
|
- 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@v4
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --snapshot
|
||||||
|
- name: Build and publish release
|
||||||
|
uses: goreleaser/goreleaser-action@v4
|
||||||
|
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+10
-1
@@ -21,13 +21,17 @@ _testmain.go
|
|||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# vim swp files
|
# vim files
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
|
||||||
# Desktop Services Store on macOS
|
# Desktop Services Store on macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
/.direnv
|
||||||
|
|
||||||
# Inbucket binaries
|
# Inbucket binaries
|
||||||
/client
|
/client
|
||||||
/client.exe
|
/client.exe
|
||||||
@@ -41,6 +45,7 @@ _testmain.go
|
|||||||
|
|
||||||
# Elm UI
|
# Elm UI
|
||||||
# elm-package generated files
|
# elm-package generated files
|
||||||
|
/ui/index.html
|
||||||
/ui/elm-stuff
|
/ui/elm-stuff
|
||||||
/ui/tests/elm-stuff
|
/ui/tests/elm-stuff
|
||||||
# elm-repl generated files
|
# elm-repl generated files
|
||||||
@@ -49,3 +54,7 @@ repl-temp-*
|
|||||||
/ui/dist/
|
/ui/dist/
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
/ui/node_modules
|
/ui/node_modules
|
||||||
|
/ui/.parcel-cache
|
||||||
|
|
||||||
|
# Test lua files
|
||||||
|
/inbucket.lua
|
||||||
|
|||||||
+28
-27
@@ -6,18 +6,13 @@ release:
|
|||||||
name: inbucket
|
name: inbucket
|
||||||
name_template: '{{.Tag}}'
|
name_template: '{{.Tag}}'
|
||||||
|
|
||||||
brew:
|
|
||||||
commit_author:
|
|
||||||
name: goreleaserbot
|
|
||||||
email: goreleaser@carlosbecker.com
|
|
||||||
install: bin.install ""
|
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod download
|
- go mod download
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: inbucket
|
- id: inbucket
|
||||||
|
binary: inbucket
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -27,11 +22,14 @@ builds:
|
|||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
|
- arm
|
||||||
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "7"
|
||||||
main: ./cmd/inbucket
|
main: ./cmd/inbucket
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
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:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -41,16 +39,18 @@ builds:
|
|||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
|
- arm
|
||||||
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "7"
|
||||||
main: ./cmd/client
|
main: ./cmd/client
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
|
||||||
archive:
|
archives:
|
||||||
|
- id: tarball
|
||||||
|
rlcp: true
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
|
||||||
.Arm }}{{ end }}'
|
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
@@ -58,24 +58,28 @@ archive:
|
|||||||
- LICENSE*
|
- LICENSE*
|
||||||
- README*
|
- README*
|
||||||
- CHANGELOG*
|
- CHANGELOG*
|
||||||
- etc/**/*
|
- etc/**
|
||||||
- ui/dist/**/*
|
- ui/dist/**
|
||||||
- ui/greeting.html
|
- ui/greeting.html
|
||||||
|
|
||||||
nfpm:
|
nfpms:
|
||||||
|
- formats:
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
vendor: inbucket.org
|
vendor: inbucket.org
|
||||||
homepage: https://www.inbucket.org/
|
homepage: https://www.inbucket.org/
|
||||||
maintainer: github@hillyerd.com
|
maintainer: github@hillyerd.com
|
||||||
description: All-in-one disposable webmail service.
|
description: All-in-one disposable webmail service.
|
||||||
license: MIT
|
license: MIT
|
||||||
formats:
|
contents:
|
||||||
- deb
|
- src: "ui/dist/**"
|
||||||
- rpm
|
dst: "/usr/local/share/inbucket/ui"
|
||||||
files:
|
- src: "etc/linux/inbucket.service"
|
||||||
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
dst: "/lib/systemd/system/inbucket.service"
|
||||||
config_files:
|
type: config|noreplace
|
||||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
- src: "ui/greeting.html"
|
||||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
dst: "/etc/inbucket/greeting.html"
|
||||||
|
type: config|noreplace
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: SNAPSHOT-{{ .Commit }}
|
name_template: SNAPSHOT-{{ .Commit }}
|
||||||
@@ -84,6 +88,3 @@ checksum:
|
|||||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||||
|
|
||||||
dist: dist
|
dist: dist
|
||||||
|
|
||||||
sign:
|
|
||||||
artifacts: none
|
|
||||||
|
|||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
sudo: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
global:
|
|
||||||
- GO111MODULE=on
|
|
||||||
|
|
||||||
language: go
|
|
||||||
|
|
||||||
install:
|
|
||||||
- "go get golang.org/x/lint/golint"
|
|
||||||
- "make deps"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- go: "1.11.x"
|
|
||||||
- go: "master"
|
|
||||||
- language: elm
|
|
||||||
elm: "0.19.0"
|
|
||||||
install:
|
|
||||||
- "cd ui"
|
|
||||||
- "npm ci"
|
|
||||||
script:
|
|
||||||
- "elm-format --validate ."
|
|
||||||
- "npm run build"
|
|
||||||
- stage: deploy
|
|
||||||
go: "1.11.x"
|
|
||||||
before_install:
|
|
||||||
- "nvm install 10.13.0"
|
|
||||||
install:
|
|
||||||
- "cd ui"
|
|
||||||
- "npm ci"
|
|
||||||
- "npm run build"
|
|
||||||
- "cd .."
|
|
||||||
script: "curl -sL https://git.io/goreleaser | bash"
|
|
||||||
addons:
|
|
||||||
apt:
|
|
||||||
packages:
|
|
||||||
- rpm
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
- name: deploy
|
|
||||||
if: tag IS present
|
|
||||||
+156
-10
@@ -4,7 +4,139 @@ Change Log
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## [v3.0.0-beta1]
|
## [v3.1.0-beta1] - 2023-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Monitor tab updates when messages are deleted (#337)
|
||||||
|
- Initial framework for extensions
|
||||||
|
- Initial Lua scripting implementation, supporting events:
|
||||||
|
- after.message_deleted
|
||||||
|
- after.message_stored
|
||||||
|
- before.mail_accepted
|
||||||
|
- Provide `http` and `json` modules for Lua scripts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Support for IP address as domain in RCPT TO (#285)
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.4] - 2022-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- More flexible support of `AUTH=<>` FROM parameter (#291)
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.3] - 2022-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Support for `AUTH=<>` FROM parameter (#284)
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.2] - 2022-07-04
|
||||||
|
|
||||||
|
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
|
||||||
|
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
|
||||||
|
details.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- arm Docker builds now rely on amd64 frontend build stage
|
||||||
|
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.1-rc2] - 2022-01-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Builds for arm7 and arm64 platforms
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Abandoned git-flow process, the `master` branch renamed to `main`
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.1-rc1] - 2022-01-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update Go dependencies
|
||||||
|
- Update NPM dependencies
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0] - 2021-09-19
|
||||||
|
|
||||||
|
Unchanged from rc4.
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-rc4] - 2021-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Various MIME header decoding improvements
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Bump Go version to 1.17 (#233)
|
||||||
|
|
||||||
|
|
||||||
|
## v3.0.0-rc3 - 2021-08-01
|
||||||
|
|
||||||
|
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
|
||||||
|
tags for Docker Hub and ghcr.io.
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-rc2] - 2021-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support for SMTP AUTH (#197, thanks makarchuk)
|
||||||
|
- Dark mode support (#218, thanks nerones)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevent potential click jacking (#190, thanks stuartskelton)
|
||||||
|
- Error on 8 character long SMTP commands (#221)
|
||||||
|
- Allow empty username and password during AUTH (#225)
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-rc1] - 2020-09-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Refresh button to reload mailbox contents
|
||||||
|
- Improved keyboard (tab) focus highlights
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The UI now includes the Open Sans webfont instead of relying on browser/OS
|
||||||
|
fonts
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta3] - 2020-09-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker `HEALTHCHECK`
|
||||||
|
- Mouse-out delay to improve pop-up menu navigation
|
||||||
|
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated frontend and backend dependencies, Docker image base
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved layout on mobile and wide displays
|
||||||
|
- Prevent unexpected input for modal dialogs
|
||||||
|
- Allow empty SMTP `MAIL FROM:<>`
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta2] - 2019-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ability to name mailboxes after domain of email recipient, set via
|
||||||
|
`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
|
### Added
|
||||||
- `posix-millis` field to REST message and header responses for easier date
|
- `posix-millis` field to REST message and header responses for easier date
|
||||||
@@ -17,12 +149,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Update to enmime v0.5.0
|
- Update to enmime v0.5.0
|
||||||
|
|
||||||
|
|
||||||
## v2.1.0
|
## v2.1.0 - 2018-12-15
|
||||||
|
|
||||||
No change from beta1.
|
No change from beta1.
|
||||||
|
|
||||||
|
|
||||||
## [v2.1.0-beta1]
|
## [v2.1.0-beta1] - 2018-10-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Use Go 1.11 modules for reproducible builds.
|
- Use Go 1.11 modules for reproducible builds.
|
||||||
@@ -195,7 +327,20 @@ No change from beta1.
|
|||||||
- Add Link button to messages, allows for directing another person to a
|
- Add Link button to messages, allows for directing another person to a
|
||||||
specific message.
|
specific message.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
|
||||||
|
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...main
|
||||||
|
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
|
||||||
|
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
|
||||||
|
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
|
||||||
|
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
|
||||||
|
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
|
||||||
|
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
|
||||||
|
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
|
||||||
|
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
|
||||||
|
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
|
||||||
|
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
|
||||||
|
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||||
|
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||||
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
[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.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]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||||
@@ -213,16 +358,17 @@ No change from beta1.
|
|||||||
|
|
||||||
## Release Checklist
|
## Release Checklist
|
||||||
|
|
||||||
1. Create release branch: `git flow release start 1.x.0`
|
1. Create a release branch
|
||||||
2. Update CHANGELOG.md:
|
2. Update CHANGELOG.md:
|
||||||
- Ensure *Unreleased* section is up to date
|
- 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
|
- Add new GitHub `/compare` link
|
||||||
|
- Update previous tag version for *Unreleased*
|
||||||
3. Run tests
|
3. Run tests
|
||||||
4. Test cross-compile: `goreleaser --snapshot`
|
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||||
5. Commit changes and merge release: `git flow release finish`
|
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||||
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
|
6. Push tags and wait for
|
||||||
complete
|
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
|
||||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||||
|
|
||||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||||
|
|||||||
+6
-19
@@ -1,11 +1,8 @@
|
|||||||
How to Contribute
|
# How to Contribute
|
||||||
=================
|
|
||||||
|
|
||||||
Inbucket encourages third-party patches. It's valuable to know how other
|
Inbucket encourages third-party patches. It's valuable to know how other
|
||||||
developers are using the product.
|
developers are using the product.
|
||||||
|
|
||||||
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
|
|||||||
|
|
||||||
## Making Changes
|
## Making Changes
|
||||||
|
|
||||||
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
Inbucket follows the regular GitHub pattern. Create a topic branch from where
|
||||||
you can run `git flow feature start <topic branch name>`.
|
you want to base your work:
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Once you are on your topic branch:
|
Once you are on your topic branch:
|
||||||
|
|
||||||
1. Make commits of logical units.
|
1. Make commits of logical units.
|
||||||
2. Add unit tests to exercise your changes.
|
2. Add unit tests to exercise your changes.
|
||||||
3. Run the updated code through `go fmt` and `go vet`.
|
3. Run `make` to test, vet and confirm your code is formatted correctly.
|
||||||
4. Ensure the code builds and tests with the following commands:
|
If you do not have Make installed, please perform these steps manually,
|
||||||
- `go clean ./...`
|
otherwise your PR will not pass our checks.
|
||||||
- `go build ./...`
|
|
||||||
- `go test ./...`
|
|
||||||
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
Thank you for contributing to Inbucket!
|
Thank you for contributing to Inbucket!
|
||||||
|
|
||||||
[git-flow]: https://github.com/nvie/gitflow
|
|
||||||
|
|||||||
+21
-11
@@ -1,8 +1,18 @@
|
|||||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
|
|
||||||
# Build
|
### Build frontend
|
||||||
FROM golang:1.11-alpine3.8 as builder
|
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||||
RUN apk add --no-cache --virtual .build-deps git make npm
|
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.19-alpine3.17 as backend
|
||||||
|
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV CGO_ENABLED 0
|
ENV CGO_ENABLED 0
|
||||||
@@ -10,17 +20,14 @@ RUN make clean deps
|
|||||||
RUN go build -o inbucket \
|
RUN go build -o inbucket \
|
||||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||||
-v ./cmd/inbucket
|
-v ./cmd/inbucket
|
||||||
WORKDIR /build/ui
|
|
||||||
RUN rm -rf dist elm-stuff node_modules
|
|
||||||
RUN npm i
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Run in minimal image
|
### Run in minimal image
|
||||||
FROM alpine:3.8
|
FROM alpine:3.17
|
||||||
|
RUN apk --no-cache add tzdata
|
||||||
WORKDIR /opt/inbucket
|
WORKDIR /opt/inbucket
|
||||||
RUN mkdir bin defaults ui
|
RUN mkdir bin defaults ui
|
||||||
COPY --from=builder /build/inbucket bin
|
COPY --from=backend /build/inbucket bin
|
||||||
COPY --from=builder /build/ui/dist ui
|
COPY --from=frontend /build/ui/dist ui
|
||||||
COPY etc/docker/defaults/greeting.html defaults
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
COPY etc/docker/defaults/start-inbucket.sh /
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|
||||||
@@ -36,6 +43,9 @@ ENV INBUCKET_STORAGE_PARAMS path:/storage
|
|||||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||||
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
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
|
# Ports: SMTP, HTTP, POP3
|
||||||
EXPOSE 2500 9000 1100
|
EXPOSE 2500 9000 1100
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ commands = client inbucket
|
|||||||
|
|
||||||
all: clean test lint build
|
all: clean test lint build
|
||||||
|
|
||||||
$(commands): %: cmd/%
|
$(commands): %: cmd/% $(SRC)
|
||||||
go build ./$<
|
go build ./$<
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
Inbucket
|

|
||||||
=============================================================================
|

|
||||||
[][Build Status]
|
|
||||||
|
# Inbucket
|
||||||
|
|
||||||
Inbucket is an email testing service; it will accept messages for any email
|
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,
|
address and make them available via web, REST and POP3 interfaces. Once
|
||||||
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
|
||||||
are all built in).
|
and storage are all built in.
|
||||||
|
|
||||||
A Go client for the REST API is available in
|
A Go client for the REST API is available in
|
||||||
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
||||||
@@ -14,6 +15,7 @@ Read more at the [Inbucket Website]
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
Inbucket is currently production quality: it is being used for real work.
|
Inbucket is currently production quality: it is being used for real work.
|
||||||
@@ -22,49 +24,66 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
|
|||||||
to contribute code to the project check out [CONTRIBUTING.md].
|
to contribute code to the project check out [CONTRIBUTING.md].
|
||||||
|
|
||||||
|
|
||||||
## Homebrew Tap
|
## Docker
|
||||||
|
|
||||||
(currently broken, being tracked in [issue
|
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||||
#68](https://github.com/inbucket/inbucket/issues/68))
|
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||||
|
`main` branch.
|
||||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
|
||||||
see the `README.md` there for installation instructions.
|
|
||||||
|
|
||||||
|
|
||||||
## Building from Source
|
## 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/inbucket/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
|
### Configure and Launch
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
|
||||||
|
|
||||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/inbucket/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
|
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 web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||||
|
|
||||||
The Inbucket website has a more complete guide to
|
See doc/[config.md] for more information on configuring Inbucket, but you will
|
||||||
[installing from source][From Source]
|
likely find the [Configurator] tool the easiest way to generate a configuration.
|
||||||
|
|
||||||
|
|
||||||
## About
|
## 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
|
Inbucket is open source software released under the MIT License. The latest
|
||||||
version can be found at https://github.com/inbucket/inbucket
|
version can be found at https://github.com/inbucket/inbucket
|
||||||
|
|
||||||
[Go API docs]: https://godoc.org/github.com/inbucket/inbucket/pkg/rest/client
|
|
||||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||||
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
|
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
|
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
[Configurator]: https://www.inbucket.org/configurator/
|
||||||
[Google Go]: http://golang.org/
|
[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]: http://brew.sh/
|
||||||
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||||
[Inbucket Website]: http://www.inbucket.org/
|
[Inbucket Website]: https://www.inbucket.org/
|
||||||
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||||
|
[Node.js]: https://nodejs.org/en/
|
||||||
|
|||||||
+30
-37
@@ -15,17 +15,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/server"
|
||||||
"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"
|
||||||
"github.com/inbucket/inbucket/pkg/storage/file"
|
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||||
"github.com/inbucket/inbucket/pkg/webui"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -71,6 +64,7 @@ func main() {
|
|||||||
config.Usage()
|
config.Usage()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process configuration.
|
// Process configuration.
|
||||||
config.Version = version
|
config.Version = version
|
||||||
config.BuildDate = date
|
config.BuildDate = date
|
||||||
@@ -83,6 +77,7 @@ func main() {
|
|||||||
conf.POP3.Debug = true
|
conf.POP3.Debug = true
|
||||||
conf.SMTP.Debug = true
|
conf.SMTP.Debug = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger setup.
|
// Logger setup.
|
||||||
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,12 +85,15 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
startupLog := log.With().Str("phase", "startup").Logger()
|
startupLog := log.With().Str("phase", "startup").Logger()
|
||||||
|
|
||||||
// Setup signal handler.
|
// Setup signal handler.
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
// Initialize logging.
|
// Initialize logging.
|
||||||
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||||
Msg("Inbucket starting")
|
Msg("Inbucket starting")
|
||||||
|
|
||||||
// Write pidfile if requested.
|
// Write pidfile if requested.
|
||||||
if *pidfile != "" {
|
if *pidfile != "" {
|
||||||
pidf, err := os.Create(*pidfile)
|
pidf, err := os.Create(*pidfile)
|
||||||
@@ -107,31 +105,18 @@ func main() {
|
|||||||
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Configure internal services.
|
|
||||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
// Configure and start internal services.
|
||||||
shutdownChan := make(chan bool)
|
svcCtx, svcCancel := context.WithCancel(context.Background())
|
||||||
store, err := storage.FromConfig(conf.Storage)
|
services, err := server.FullAssembly(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
|
||||||
removePIDFile(*pidfile)
|
removePIDFile(*pidfile)
|
||||||
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
|
|
||||||
}
|
}
|
||||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
services.Start(svcCtx, func() {
|
||||||
addrPolicy := &policy.Addressing{Config: conf}
|
startupLog.Debug().Msg("All services report ready")
|
||||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
})
|
||||||
// Start Retention scanner.
|
|
||||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
|
||||||
retentionScanner.Start()
|
|
||||||
// Start HTTP server.
|
|
||||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
|
||||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
|
||||||
go web.Start(rootCtx)
|
|
||||||
// Start POP3 server.
|
|
||||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
|
||||||
go pop3Server.Start(rootCtx)
|
|
||||||
// Start SMTP server.
|
|
||||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
|
||||||
go smtpServer.Start(rootCtx)
|
|
||||||
// Loop forever waiting for signals or shutdown channel.
|
// Loop forever waiting for signals or shutdown channel.
|
||||||
signalLoop:
|
signalLoop:
|
||||||
for {
|
for {
|
||||||
@@ -142,23 +127,31 @@ signalLoop:
|
|||||||
// Shutdown requested
|
// Shutdown requested
|
||||||
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
|
||||||
Msg("Received SIGINT, shutting down")
|
Msg("Received SIGINT, shutting down")
|
||||||
close(shutdownChan)
|
svcCancel()
|
||||||
|
break signalLoop
|
||||||
case syscall.SIGTERM:
|
case syscall.SIGTERM:
|
||||||
// Shutdown requested
|
// Shutdown requested
|
||||||
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
||||||
Msg("Received SIGTERM, shutting down")
|
Msg("Received SIGTERM, shutting down")
|
||||||
close(shutdownChan)
|
svcCancel()
|
||||||
|
break signalLoop
|
||||||
}
|
}
|
||||||
case <-shutdownChan:
|
case <-services.Notify():
|
||||||
rootCancel()
|
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
|
||||||
|
svcCancel()
|
||||||
break signalLoop
|
break signalLoop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for active connections to finish.
|
// Wait for active connections to finish.
|
||||||
go timedExit(*pidfile)
|
go timedExit(*pidfile)
|
||||||
smtpServer.Drain()
|
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
|
||||||
pop3Server.Drain()
|
services.SMTPServer.Drain()
|
||||||
retentionScanner.Join()
|
log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
|
||||||
|
services.POP3Server.Drain()
|
||||||
|
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
|
||||||
|
services.RetentionScanner.Join()
|
||||||
|
|
||||||
removePIDFile(*pidfile)
|
removePIDFile(*pidfile)
|
||||||
closeLog()
|
closeLog()
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -9,7 +9,8 @@ variables it supports:
|
|||||||
|
|
||||||
KEY DEFAULT DESCRIPTION
|
KEY DEFAULT DESCRIPTION
|
||||||
INBUCKET_LOGLEVEL info debug, info, warn, or error
|
INBUCKET_LOGLEVEL info debug, info, warn, or error
|
||||||
INBUCKET_MAILBOXNAMING local Use local or full addressing
|
INBUCKET_LUA_PATH inbucket.lua Lua script path
|
||||||
|
INBUCKET_MAILBOXNAMING local Use local, full, or domain addressing
|
||||||
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
|
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
|
||||||
INBUCKET_SMTP_DOMAIN inbucket HELO domain
|
INBUCKET_SMTP_DOMAIN inbucket HELO domain
|
||||||
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
|
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
|
||||||
@@ -28,6 +29,7 @@ variables it supports:
|
|||||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||||
|
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
|
||||||
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||||
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||||
@@ -55,6 +57,16 @@ off with `warn` or `error`.
|
|||||||
- Default: `info`
|
- Default: `info`
|
||||||
- Values: one of `debug`, `info`, `warn`, or `error`
|
- Values: one of `debug`, `info`, `warn`, or `error`
|
||||||
|
|
||||||
|
### Lua Script
|
||||||
|
|
||||||
|
`INBUCKET_LUA_PATH`
|
||||||
|
|
||||||
|
This is the path to the (optional) Inbucket Lua script. If the specified file
|
||||||
|
is present, Inbucket will load it during startup. Ignored if the file is not
|
||||||
|
found, or the setting is empty.
|
||||||
|
|
||||||
|
- Default: `inbucket.lua`
|
||||||
|
|
||||||
### Mailbox Naming
|
### Mailbox Naming
|
||||||
|
|
||||||
`INBUCKET_MAILBOXNAMING`
|
`INBUCKET_MAILBOXNAMING`
|
||||||
@@ -76,8 +88,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
|
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||||
incorporated into the mailbox name.
|
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`
|
- Default: `local`
|
||||||
- Values: one of `local` or `full`
|
- Values: one of `local` or `full` or `domain`
|
||||||
|
|
||||||
|
|
||||||
## SMTP
|
## SMTP
|
||||||
@@ -225,7 +243,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
|||||||
|
|
||||||
### TLS Public Certificate File
|
### TLS Public Certificate File
|
||||||
|
|
||||||
`INBUCKET_SMTP_TLSPRIVKEY`
|
`INBUCKET_SMTP_TLSCERT`
|
||||||
|
|
||||||
Specify the x509 Certificate file to be used for TLS negotiation.
|
Specify the x509 Certificate file to be used for TLS negotiation.
|
||||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||||
@@ -284,6 +302,24 @@ Inbucket to listen on all available network interfaces.
|
|||||||
|
|
||||||
- Default: `0.0.0.0:9000`
|
- 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
|
### UI Directory
|
||||||
|
|
||||||
`INBUCKET_WEB_UIDIR`
|
`INBUCKET_WEB_UIDIR`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# description: Developer friendly Inbucket configuration
|
# description: Developer friendly Inbucket configuration
|
||||||
|
|
||||||
export INBUCKET_LOGLEVEL="debug"
|
export INBUCKET_LOGLEVEL="debug"
|
||||||
|
#export INBUCKET_MAILBOXNAMING="domain"
|
||||||
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||||
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||||
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||||
@@ -13,6 +14,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
|
|||||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||||
export INBUCKET_WEB_UIDIR="ui/dist"
|
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||||
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||||
|
#export INBUCKET_WEB_BASEPATH="prefix"
|
||||||
export INBUCKET_STORAGE_TYPE="file"
|
export INBUCKET_STORAGE_TYPE="file"
|
||||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||||
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# description: Launch Inbucket's docker image
|
# description: Launch Inbucket's docker image
|
||||||
|
|
||||||
# Docker Image Tag
|
# Docker Image Tag
|
||||||
IMAGE="jhillyerd/inbucket"
|
IMAGE="inbucket/inbucket:edge"
|
||||||
|
|
||||||
# Ports exposed on host:
|
# Ports exposed on host:
|
||||||
PORT_HTTP=9000
|
PORT_HTTP=9000
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# rest-apiv1.sh
|
# rest-apiv1.sh
|
||||||
# description: Script to access Inbucket REST API version 1
|
# description: Script to access Inbucket REST API version 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# run-tests.sh
|
# run-tests.sh
|
||||||
# description: Generate test emails for Inbucket
|
# description: Generate test emails for Inbucket
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
esac
|
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"
|
export SWAKS_OPT_to="$to@inbucket.local"
|
||||||
|
|
||||||
# Basic test
|
# Basic test
|
||||||
@@ -59,3 +59,7 @@ swaks $* --data nonmime-html-inlined.raw
|
|||||||
|
|
||||||
# Incorrect charset, malformed final boundary
|
# Incorrect charset, malformed final boundary
|
||||||
swaks $* --data mime-errors.raw
|
swaks $* --data mime-errors.raw
|
||||||
|
|
||||||
|
# IP RCPT domain
|
||||||
|
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body text.txt
|
||||||
|
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body text.txt
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
module github.com/inbucket/inbucket
|
module github.com/inbucket/inbucket
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
|
github.com/google/subcommands v1.2.0
|
||||||
github.com/gorilla/context v1.1.1 // indirect
|
|
||||||
github.com/gorilla/css v1.0.0
|
github.com/gorilla/css v1.0.0
|
||||||
github.com/gorilla/mux v1.6.2
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/websocket v1.4.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/jhillyerd/enmime v0.5.0
|
github.com/inbucket/gopher-json v0.2.0
|
||||||
|
github.com/jhillyerd/enmime v0.11.0
|
||||||
github.com/jhillyerd/goldiff v0.1.0
|
github.com/jhillyerd/goldiff v0.1.0
|
||||||
github.com/kelseyhightower/envconfig v1.3.0
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.1
|
github.com/microcosm-cc/bluemonday v1.0.22
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/rs/zerolog v1.29.0
|
||||||
github.com/rs/zerolog v1.9.1
|
github.com/stretchr/testify v1.8.2
|
||||||
github.com/stretchr/testify v1.2.2
|
github.com/yuin/gopher-lua v1.1.0
|
||||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
|
golang.org/x/net v0.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/go-test/deep v1.1.0 // indirect
|
||||||
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.4 // indirect
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||||
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
|
golang.org/x/text v0.7.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
go 1.18
|
||||||
|
|||||||
@@ -1,46 +1,124 @@
|
|||||||
|
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/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
|
||||||
|
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
|
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
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 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
|
||||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
|
||||||
github.com/jhillyerd/enmime v0.5.0 h1:0U3mdCFsFsdQcuztie8g+eQgZ2GhtVCun8AJArOqdzc=
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
github.com/jhillyerd/enmime v0.5.0/go.mod h1:/bb6lwXIWgsVnrO4uuLg8rBVqidJYeG9I34d/WfWurg=
|
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.11.0 h1:5EOSLh7l3eMODznfMCKVGQY74Qb95Yfuet6CYgquLfM=
|
||||||
|
github.com/jhillyerd/enmime v0.11.0/go.mod h1:nw2aJ34YXWklLze+qEESgP+KNhU3fMQuiFsD/4soh3Q=
|
||||||
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||||
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
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.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.22 h1:p2tT7RNzRdCi0qmwxG+HbqD6ILkmwter1ZwVZn1oTxA=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.22/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
||||||
|
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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||||
|
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
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/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
||||||
|
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
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.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+11
-1
@@ -38,6 +38,7 @@ const (
|
|||||||
UnknownNaming mbNaming = iota
|
UnknownNaming mbNaming = iota
|
||||||
LocalNaming
|
LocalNaming
|
||||||
FullNaming
|
FullNaming
|
||||||
|
DomainNaming
|
||||||
)
|
)
|
||||||
|
|
||||||
// Decode a naming strategy from string.
|
// Decode a naming strategy from string.
|
||||||
@@ -47,6 +48,8 @@ func (n *mbNaming) Decode(v string) error {
|
|||||||
*n = LocalNaming
|
*n = LocalNaming
|
||||||
case "full":
|
case "full":
|
||||||
*n = FullNaming
|
*n = FullNaming
|
||||||
|
case "domain":
|
||||||
|
*n = DomainNaming
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||||
}
|
}
|
||||||
@@ -56,13 +59,19 @@ func (n *mbNaming) Decode(v string) error {
|
|||||||
// Root contains global configuration, and structs with for specific sub-systems.
|
// Root contains global configuration, and structs with for specific sub-systems.
|
||||||
type Root struct {
|
type Root struct {
|
||||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
|
Lua Lua
|
||||||
|
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
|
||||||
SMTP SMTP
|
SMTP SMTP
|
||||||
POP3 POP3
|
POP3 POP3
|
||||||
Web Web
|
Web Web
|
||||||
Storage Storage
|
Storage Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lua contains the Lua extension host configuration.
|
||||||
|
type Lua struct {
|
||||||
|
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
|
||||||
|
}
|
||||||
|
|
||||||
// SMTP contains the SMTP server configuration.
|
// SMTP contains the SMTP server configuration.
|
||||||
type SMTP struct {
|
type SMTP struct {
|
||||||
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||||
@@ -93,6 +102,7 @@ type POP3 struct {
|
|||||||
// Web contains the HTTP server configuration.
|
// Web contains the HTTP server configuration.
|
||||||
type Web struct {
|
type Web struct {
|
||||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||||
|
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
|
||||||
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||||
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AsyncEventBroker maintains a list of listeners interested in a specific type
|
||||||
|
// of event. Events are sent in parallel to all listeners, and no result is
|
||||||
|
// returned.
|
||||||
|
type AsyncEventBroker[E any] struct {
|
||||||
|
sync.RWMutex
|
||||||
|
listenerNames []string // Ordered listener names.
|
||||||
|
listenerFuncs []func(E) // Ordered listener functions.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit sends the provided event to each registered listener in parallel.
|
||||||
|
func (eb *AsyncEventBroker[E]) Emit(event *E) {
|
||||||
|
eb.RLock()
|
||||||
|
defer eb.RUnlock()
|
||||||
|
|
||||||
|
for _, l := range eb.listenerFuncs {
|
||||||
|
// Events are copied to minimize the risk of mutation.
|
||||||
|
go l(*event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddListener registers the named listener, replacing one with a duplicate
|
||||||
|
// name if present. Listeners should be added in order of priority, most
|
||||||
|
// significant first.
|
||||||
|
func (eb *AsyncEventBroker[E]) AddListener(name string, listener func(E)) {
|
||||||
|
eb.Lock()
|
||||||
|
defer eb.Unlock()
|
||||||
|
|
||||||
|
eb.lockedRemoveListener(name)
|
||||||
|
eb.listenerNames = append(eb.listenerNames, name)
|
||||||
|
eb.listenerFuncs = append(eb.listenerFuncs, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveListener unregisters the named listener.
|
||||||
|
func (eb *AsyncEventBroker[E]) RemoveListener(name string) {
|
||||||
|
eb.Lock()
|
||||||
|
defer eb.Unlock()
|
||||||
|
|
||||||
|
eb.lockedRemoveListener(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eb *AsyncEventBroker[E]) lockedRemoveListener(name string) {
|
||||||
|
for i, entry := range eb.listenerNames {
|
||||||
|
if entry == name {
|
||||||
|
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
|
||||||
|
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsyncTestListener returns a func that will wait for an event and return it, or timeout
|
||||||
|
// with an error.
|
||||||
|
func (eb *AsyncEventBroker[E]) AsyncTestListener(name string, capacity int) func() (*E, error) {
|
||||||
|
// Send event down channel.
|
||||||
|
events := make(chan E, capacity)
|
||||||
|
eb.AddListener(name,
|
||||||
|
func(msg E) {
|
||||||
|
events <- msg
|
||||||
|
})
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
return func() (*E, error) {
|
||||||
|
count++
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if count >= capacity {
|
||||||
|
eb.RemoveListener(name)
|
||||||
|
close(events)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case event := <-events:
|
||||||
|
return &event, nil
|
||||||
|
|
||||||
|
case <-time.After(time.Second * 2):
|
||||||
|
return nil, errors.New("Timeout waiting for event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package extension_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple smoke test without using AsyncTestListener.
|
||||||
|
func TestAsyncBrokerEmitCallsOneListener(t *testing.T) {
|
||||||
|
broker := &extension.AsyncEventBroker[string]{}
|
||||||
|
|
||||||
|
// Setup listener.
|
||||||
|
events := make(chan string, 1)
|
||||||
|
listener := func(s string) {
|
||||||
|
events <- s
|
||||||
|
}
|
||||||
|
broker.AddListener("x", listener)
|
||||||
|
|
||||||
|
want := "bacon"
|
||||||
|
broker.Emit(&want)
|
||||||
|
|
||||||
|
var got string
|
||||||
|
select {
|
||||||
|
case event := <-events:
|
||||||
|
got = event
|
||||||
|
|
||||||
|
case <-time.After(time.Second * 2):
|
||||||
|
t.Fatal("Timeout waiting for event")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Emit got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncBrokerEmitCallsMultipleListeners(t *testing.T) {
|
||||||
|
broker := &extension.AsyncEventBroker[string]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
first := broker.AsyncTestListener("first", 1)
|
||||||
|
second := broker.AsyncTestListener("second", 1)
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
|
||||||
|
first_got, err := first()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, *first_got)
|
||||||
|
|
||||||
|
second_got, err := second()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, *second_got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
|
||||||
|
broker := &extension.AsyncEventBroker[string]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
first := broker.AsyncTestListener("dup", 1)
|
||||||
|
second := broker.AsyncTestListener("dup", 1)
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
|
||||||
|
first_got, err := first()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, first_got)
|
||||||
|
|
||||||
|
second_got, err := second()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, *second_got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
|
||||||
|
broker := &extension.AsyncEventBroker[string]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
first := broker.AsyncTestListener("1", 1)
|
||||||
|
second := broker.AsyncTestListener("2", 1)
|
||||||
|
broker.RemoveListener("1")
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
|
||||||
|
first_got, err := first()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, first_got)
|
||||||
|
|
||||||
|
second_got, err := second()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, *second_got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncBrokerRemovingMissingListener(t *testing.T) {
|
||||||
|
broker := &extension.AsyncEventBroker[string]{}
|
||||||
|
broker.RemoveListener("doesn't crash")
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventBroker maintains a list of listeners interested in a specific type
|
||||||
|
// of event.
|
||||||
|
type EventBroker[E any, R comparable] struct {
|
||||||
|
sync.RWMutex
|
||||||
|
listenerNames []string // Ordered listener names.
|
||||||
|
listenerFuncs []func(E) *R // Ordered listener functions.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit sends the provided event to each registered listener in order, until
|
||||||
|
// one returns a non-nil result. That result will be returned to the caller.
|
||||||
|
func (eb *EventBroker[E, R]) Emit(event *E) *R {
|
||||||
|
eb.RLock()
|
||||||
|
defer eb.RUnlock()
|
||||||
|
|
||||||
|
for _, l := range eb.listenerFuncs {
|
||||||
|
// Events are copied to minimize the risk of mutation.
|
||||||
|
if result := l(*event); result != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddListener registers the named listener, replacing one with a duplicate
|
||||||
|
// name if present. Listeners should be added in order of priority, most
|
||||||
|
// significant first.
|
||||||
|
func (eb *EventBroker[E, R]) AddListener(name string, listener func(E) *R) {
|
||||||
|
eb.Lock()
|
||||||
|
defer eb.Unlock()
|
||||||
|
|
||||||
|
eb.lockedRemoveListener(name)
|
||||||
|
eb.listenerNames = append(eb.listenerNames, name)
|
||||||
|
eb.listenerFuncs = append(eb.listenerFuncs, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveListener unregisters the named listener.
|
||||||
|
func (eb *EventBroker[E, R]) RemoveListener(name string) {
|
||||||
|
eb.Lock()
|
||||||
|
defer eb.Unlock()
|
||||||
|
|
||||||
|
eb.lockedRemoveListener(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eb *EventBroker[E, R]) lockedRemoveListener(name string) {
|
||||||
|
for i, entry := range eb.listenerNames {
|
||||||
|
if entry == name {
|
||||||
|
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
|
||||||
|
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package extension_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBrokerEmitCallsOneListener(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[string, bool]{}
|
||||||
|
|
||||||
|
// Setup listener.
|
||||||
|
var got string
|
||||||
|
listener := func(s string) *bool {
|
||||||
|
got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
broker.AddListener("x", listener)
|
||||||
|
|
||||||
|
want := "bacon"
|
||||||
|
broker.Emit(&want)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Emit got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[string, bool]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
var first_got, second_got string
|
||||||
|
first := func(s string) *bool {
|
||||||
|
first_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
second := func(s string) *bool {
|
||||||
|
second_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
broker.AddListener("1", first)
|
||||||
|
broker.AddListener("2", second)
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
if first_got != want {
|
||||||
|
t.Errorf("first got %q, want %q", first_got, want)
|
||||||
|
}
|
||||||
|
if second_got != want {
|
||||||
|
t.Errorf("second got %q, want %q", second_got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerEmitCapturesFirstResult(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[struct{}, string]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
makeListener := func(result *string) func(struct{}) *string {
|
||||||
|
return func(s struct{}) *string { return result }
|
||||||
|
}
|
||||||
|
first := "first"
|
||||||
|
second := "second"
|
||||||
|
broker.AddListener("0", makeListener(nil))
|
||||||
|
broker.AddListener("1", makeListener(&first))
|
||||||
|
broker.AddListener("2", makeListener(&second))
|
||||||
|
|
||||||
|
want := first
|
||||||
|
got := broker.Emit(&struct{}{})
|
||||||
|
if got == nil {
|
||||||
|
t.Errorf("Emit got nil, want %q", want)
|
||||||
|
} else if *got != want {
|
||||||
|
t.Errorf("Emit got %q, want %q", *got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[string, bool]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
var first_got, second_got string
|
||||||
|
first := func(s string) *bool {
|
||||||
|
first_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
second := func(s string) *bool {
|
||||||
|
second_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
broker.AddListener("dup", first)
|
||||||
|
broker.AddListener("dup", second)
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
if first_got != "" {
|
||||||
|
t.Errorf("first got %q, want empty string", first_got)
|
||||||
|
}
|
||||||
|
if second_got != want {
|
||||||
|
t.Errorf("second got %q, want %q", second_got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerRemovingListenerSuccessful(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[string, bool]{}
|
||||||
|
|
||||||
|
// Setup listeners.
|
||||||
|
var first_got, second_got string
|
||||||
|
first := func(s string) *bool {
|
||||||
|
first_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
second := func(s string) *bool {
|
||||||
|
second_got = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
broker.AddListener("1", first)
|
||||||
|
broker.AddListener("2", second)
|
||||||
|
broker.RemoveListener("1")
|
||||||
|
|
||||||
|
want := "hi"
|
||||||
|
broker.Emit(&want)
|
||||||
|
if first_got != "" {
|
||||||
|
t.Errorf("first got %q, want empty string", first_got)
|
||||||
|
}
|
||||||
|
if second_got != want {
|
||||||
|
t.Errorf("second got %q, want %q", second_got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerRemovingMissingListener(t *testing.T) {
|
||||||
|
broker := &extension.EventBroker[string, bool]{}
|
||||||
|
broker.RemoveListener("doesn't crash")
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddressParts contains the local and domain parts of an email address.
|
||||||
|
type AddressParts struct {
|
||||||
|
Local string
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageMetadata contains the basic header data for a message event.
|
||||||
|
type MessageMetadata struct {
|
||||||
|
Mailbox string
|
||||||
|
ID string
|
||||||
|
From *mail.Address
|
||||||
|
To []*mail.Address
|
||||||
|
Date time.Time
|
||||||
|
Subject string
|
||||||
|
Size int64
|
||||||
|
Seen bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Host defines extension points for Inbucket.
|
||||||
|
type Host struct {
|
||||||
|
Events *Events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events defines all the event types supported by the extension host.
|
||||||
|
//
|
||||||
|
// Before-events provide an opportunity for extensions to alter how Inbucket responds to that type
|
||||||
|
// of event. These events are processed synchronously; expensive operations will reduce the
|
||||||
|
// perceived performance of Inbucket. The first listener in the list to respond with a non-nil
|
||||||
|
// value will determine the response, and the remaining listeners will not be called.
|
||||||
|
//
|
||||||
|
// After-events allow extensions to take an action after an event has completed. These events are
|
||||||
|
// processed asynchronously with respect to the rest of Inbuckets operation. However, an event
|
||||||
|
// listener will not be called until the one before it complets.
|
||||||
|
type Events struct {
|
||||||
|
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
|
||||||
|
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
|
||||||
|
BeforeMailAccepted EventBroker[event.AddressParts, bool]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Void indicates the event emitter will ignore any value returned by listeners.
|
||||||
|
type Void struct{}
|
||||||
|
|
||||||
|
// NewHost creates a new extension host.
|
||||||
|
func NewHost() *Host {
|
||||||
|
return &Host{Events: &Events{}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mailAddressName = "address"
|
||||||
|
|
||||||
|
func registerMailAddressType(ls *lua.LState) {
|
||||||
|
mt := ls.NewTypeMetatable(mailAddressName)
|
||||||
|
ls.SetGlobal(mailAddressName, mt)
|
||||||
|
|
||||||
|
// Static attributes.
|
||||||
|
ls.SetField(mt, "new", ls.NewFunction(newMailAddress))
|
||||||
|
|
||||||
|
// Methods.
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(mailAddressIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(mailAddressNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMailAddress(ls *lua.LState) int {
|
||||||
|
val := &mail.Address{
|
||||||
|
Name: ls.CheckString(1),
|
||||||
|
Address: ls.CheckString(2),
|
||||||
|
}
|
||||||
|
ud := wrapMailAddress(ls, val)
|
||||||
|
ls.Push(ud)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapMailAddress(ls *lua.LState, val *mail.Address) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(mailAddressName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapMailAddress(ud *lua.LUserData) (*mail.Address, bool) {
|
||||||
|
val, ok := ud.Value.(*mail.Address)
|
||||||
|
return val, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMailAddress(ls *lua.LState, pos int) *mail.Address {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if val, ok := ud.Value.(*mail.Address); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
ls.ArgError(1, mailAddressName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets a field value from MailAddress user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||||
|
func mailAddressIndex(ls *lua.LState) int {
|
||||||
|
a := checkMailAddress(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "name":
|
||||||
|
ls.Push(lua.LString(a.Name))
|
||||||
|
case "address":
|
||||||
|
ls.Push(lua.LString(a.Address))
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets a field value on MailAddress user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||||
|
func mailAddressNewIndex(ls *lua.LState) int {
|
||||||
|
a := checkMailAddress(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "name":
|
||||||
|
a.Name = ls.CheckString(3)
|
||||||
|
case "address":
|
||||||
|
a.Address = ls.CheckString(3)
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMailAddressGetters(t *testing.T) {
|
||||||
|
want := &mail.Address{
|
||||||
|
Name: "Roberto I",
|
||||||
|
Address: "ri@example.com",
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(addr, "addr should not be nil")
|
||||||
|
|
||||||
|
want = "Roberto I"
|
||||||
|
got = addr.name
|
||||||
|
assert(got == want, string.format("got name %q, want %q", got, want))
|
||||||
|
|
||||||
|
want = "ri@example.com"
|
||||||
|
got = addr.address
|
||||||
|
assert(got == want, string.format("got address %q, want %q", got, want))
|
||||||
|
`
|
||||||
|
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("addr", wrapMailAddress(ls, want))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailAddressSetters(t *testing.T) {
|
||||||
|
want := &mail.Address{
|
||||||
|
Name: "Roberto I",
|
||||||
|
Address: "ri@example.com",
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(addr, "addr should not be nil")
|
||||||
|
|
||||||
|
addr.name = "Roberto I"
|
||||||
|
addr.address = "ri@example.com"
|
||||||
|
`
|
||||||
|
|
||||||
|
got := &mail.Address{}
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("addr", wrapMailAddress(ls, got))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
inbucketName = "inbucket"
|
||||||
|
inbucketBeforeName = "inbucket_before"
|
||||||
|
inbucketAfterName = "inbucket_after"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inbucket is the primary Lua interface data structure.
|
||||||
|
type Inbucket struct {
|
||||||
|
After InbucketAfterFuncs
|
||||||
|
Before InbucketBeforeFuncs
|
||||||
|
}
|
||||||
|
|
||||||
|
// InbucketAfterFuncs holds references to Lua extension functions to be called async
|
||||||
|
// after Inbucket handles an event.
|
||||||
|
type InbucketAfterFuncs struct {
|
||||||
|
MessageDeleted *lua.LFunction
|
||||||
|
MessageStored *lua.LFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
// InbucketBeforeFuncs holds references to Lua extension functions to be called
|
||||||
|
// before Inbucket handles an event.
|
||||||
|
type InbucketBeforeFuncs struct {
|
||||||
|
MailAccepted *lua.LFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerInbucketTypes(ls *lua.LState) {
|
||||||
|
// inbucket type.
|
||||||
|
mt := ls.NewTypeMetatable(inbucketName)
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(inbucketIndex))
|
||||||
|
|
||||||
|
// inbucket global var.
|
||||||
|
ud := wrapInbucket(ls, &Inbucket{})
|
||||||
|
ls.SetGlobal(inbucketName, ud)
|
||||||
|
|
||||||
|
// inbucket.after type.
|
||||||
|
mt = ls.NewTypeMetatable(inbucketAfterName)
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketAfterNewIndex))
|
||||||
|
|
||||||
|
// inbucket.before type.
|
||||||
|
mt = ls.NewTypeMetatable(inbucketBeforeName)
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(inbucketBeforeIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketBeforeNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInbucket(ls *lua.LState, val *Inbucket) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInbucketAfter(ls *lua.LState, val *InbucketAfterFuncs) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketAfterName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInbucketBefore(ls *lua.LState, val *InbucketBeforeFuncs) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketBeforeName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInbucket(ls *lua.LState) (*Inbucket, error) {
|
||||||
|
lv := ls.GetGlobal(inbucketName)
|
||||||
|
if lv == nil {
|
||||||
|
return nil, errors.New("inbucket object was nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
ud, ok := lv.(*lua.LUserData)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("inbucket object was type %s instead of UserData", lv.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := ud.Value.(*Inbucket)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("inbucket object (%v) could not be cast", ud.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInbucket(ls *lua.LState, pos int) *Inbucket {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if val, ok := ud.Value.(*Inbucket); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
ls.ArgError(1, inbucketName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInbucketAfter(ls *lua.LState, pos int) *InbucketAfterFuncs {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if val, ok := ud.Value.(*InbucketAfterFuncs); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
ls.ArgError(1, inbucketAfterName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInbucketBefore(ls *lua.LState, pos int) *InbucketBeforeFuncs {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if val, ok := ud.Value.(*InbucketBeforeFuncs); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
ls.ArgError(1, inbucketBeforeName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket getter.
|
||||||
|
func inbucketIndex(ls *lua.LState) int {
|
||||||
|
ib := checkInbucket(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "after":
|
||||||
|
ls.Push(wrapInbucketAfter(ls, &ib.After))
|
||||||
|
case "before":
|
||||||
|
ls.Push(wrapInbucketBefore(ls, &ib.Before))
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.after getter.
|
||||||
|
func inbucketAfterIndex(ls *lua.LState) int {
|
||||||
|
after := checkInbucketAfter(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "message_deleted":
|
||||||
|
ls.Push(funcOrNil(after.MessageDeleted))
|
||||||
|
case "message_stored":
|
||||||
|
ls.Push(funcOrNil(after.MessageStored))
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.after setter.
|
||||||
|
func inbucketAfterNewIndex(ls *lua.LState) int {
|
||||||
|
m := checkInbucketAfter(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "message_deleted":
|
||||||
|
m.MessageDeleted = ls.CheckFunction(3)
|
||||||
|
case "message_stored":
|
||||||
|
m.MessageStored = ls.CheckFunction(3)
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid inbucket.after index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.before getter.
|
||||||
|
func inbucketBeforeIndex(ls *lua.LState) int {
|
||||||
|
before := checkInbucketBefore(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "mail_accepted":
|
||||||
|
ls.Push(funcOrNil(before.MailAccepted))
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbucket.before setter.
|
||||||
|
func inbucketBeforeNewIndex(ls *lua.LState) int {
|
||||||
|
m := checkInbucketBefore(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "mail_accepted":
|
||||||
|
m.MailAccepted = ls.CheckFunction(3)
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid inbucket.before index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcOrNil(f *lua.LFunction) lua.LValue {
|
||||||
|
if f == nil {
|
||||||
|
return lua.LNil
|
||||||
|
}
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInbucketAfterFuncs(t *testing.T) {
|
||||||
|
// This Script registers each function and calls it. No effort is made to use the arguments
|
||||||
|
// that Inbucket expects, this is only to validate the inbucket.after data structure getters
|
||||||
|
// and setters.
|
||||||
|
script := `
|
||||||
|
assert(inbucket, "inbucket should not be nil")
|
||||||
|
assert(inbucket.after, "inbucket.after should not be nil")
|
||||||
|
|
||||||
|
local fns = { "message_deleted", "message_stored" }
|
||||||
|
|
||||||
|
-- Verify functions start off nil.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
assert(inbucket.after[name] == nil, "after." .. name .. " should be nil")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test function to track func calls made, ensures no crossed wires.
|
||||||
|
local calls = {}
|
||||||
|
function makeTestFunc(create_name)
|
||||||
|
return function(call_name)
|
||||||
|
calls[create_name] = call_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set after functions, verify not nil, and call them.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
inbucket.after[name] = makeTestFunc(name)
|
||||||
|
assert(inbucket.after[name], "after." .. name .. " should not be nil")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Call each function. Separate loop to verify final state in 'calls'.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
inbucket.after[name](name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Verify functions were called.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
assert(calls[name], "after." .. name .. " should have been called")
|
||||||
|
assert(calls[name] == name,
|
||||||
|
string.format("after.%s was called with incorrect argument %s", name, calls[name]))
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInbucketBeforeFuncs(t *testing.T) {
|
||||||
|
// This Script registers each function and calls it. No effort is made to use the arguments
|
||||||
|
// that Inbucket expects, this is only to validate the inbucket.before data structure getters
|
||||||
|
// and setters.
|
||||||
|
script := `
|
||||||
|
assert(inbucket, "inbucket should not be nil")
|
||||||
|
assert(inbucket.before, "inbucket.before should not be nil")
|
||||||
|
|
||||||
|
local fns = { "mail_accepted" }
|
||||||
|
|
||||||
|
-- Verify functions start off nil.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
assert(inbucket.before[name] == nil, "before." .. name .. " should be nil")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test function to track func calls made, ensures no crossed wires.
|
||||||
|
local calls = {}
|
||||||
|
function makeTestFunc(create_name)
|
||||||
|
return function(call_name)
|
||||||
|
calls[create_name] = call_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set before functions, verify not nil, and call them.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
inbucket.before[name] = makeTestFunc(name)
|
||||||
|
assert(inbucket.before[name], "before." .. name .. " should not be nil")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Call each function. Separate loop to verify final state in 'calls'.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
inbucket.before[name](name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Verify functions were called.
|
||||||
|
for i, name in ipairs(fns) do
|
||||||
|
assert(calls[name], "before." .. name .. " should have been called")
|
||||||
|
assert(calls[name] == name,
|
||||||
|
string.format("before.%s was called with incorrect argument %s", name, calls[name]))
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const messageMetadataName = "message_metadata"
|
||||||
|
|
||||||
|
func registerMessageMetadataType(ls *lua.LState) {
|
||||||
|
mt := ls.NewTypeMetatable(messageMetadataName)
|
||||||
|
ls.SetGlobal(messageMetadataName, mt)
|
||||||
|
|
||||||
|
// Static attributes.
|
||||||
|
ls.SetField(mt, "new", ls.NewFunction(newMessageMetadata))
|
||||||
|
|
||||||
|
// Methods.
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(messageMetadataIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(messageMetadataNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessageMetadata(ls *lua.LState) int {
|
||||||
|
val := &event.MessageMetadata{}
|
||||||
|
ud := wrapMessageMetadata(ls, val)
|
||||||
|
ls.Push(ud)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapMessageMetadata(ls *lua.LState, val *event.MessageMetadata) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(messageMetadataName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMessageMetadata(ls *lua.LState, pos int) *event.MessageMetadata {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if v, ok := ud.Value.(*event.MessageMetadata); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
ls.ArgError(1, messageMetadataName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets a field value from MessageMetadata user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||||
|
func messageMetadataIndex(ls *lua.LState) int {
|
||||||
|
m := checkMessageMetadata(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "mailbox":
|
||||||
|
ls.Push(lua.LString(m.Mailbox))
|
||||||
|
case "id":
|
||||||
|
ls.Push(lua.LString(m.ID))
|
||||||
|
case "from":
|
||||||
|
ls.Push(wrapMailAddress(ls, m.From))
|
||||||
|
case "to":
|
||||||
|
lt := &lua.LTable{}
|
||||||
|
for _, v := range m.To {
|
||||||
|
lt.Append(wrapMailAddress(ls, v))
|
||||||
|
}
|
||||||
|
ls.Push(lt)
|
||||||
|
case "date":
|
||||||
|
ls.Push(lua.LNumber(m.Date.Unix()))
|
||||||
|
case "subject":
|
||||||
|
ls.Push(lua.LString(m.Subject))
|
||||||
|
case "size":
|
||||||
|
ls.Push(lua.LNumber(m.Size))
|
||||||
|
default:
|
||||||
|
// Unknown field.
|
||||||
|
ls.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets a field value on MessageMetadata user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||||
|
func messageMetadataNewIndex(ls *lua.LState) int {
|
||||||
|
m := checkMessageMetadata(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "mailbox":
|
||||||
|
m.Mailbox = ls.CheckString(3)
|
||||||
|
case "id":
|
||||||
|
m.ID = ls.CheckString(3)
|
||||||
|
case "from":
|
||||||
|
m.From = checkMailAddress(ls, 3)
|
||||||
|
case "to":
|
||||||
|
lt := ls.CheckTable(3)
|
||||||
|
to := make([]*mail.Address, 0, 16)
|
||||||
|
lt.ForEach(func(k, lv lua.LValue) {
|
||||||
|
if ud, ok := lv.(*lua.LUserData); ok {
|
||||||
|
if entry, ok := unwrapMailAddress(ud); ok {
|
||||||
|
to = append(to, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.To = to
|
||||||
|
case "date":
|
||||||
|
m.Date = time.Unix(ls.CheckInt64(3), 0)
|
||||||
|
case "subject":
|
||||||
|
m.Subject = ls.CheckString(3)
|
||||||
|
case "size":
|
||||||
|
m.Size = ls.CheckInt64(3)
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMessageMetadataGetters(t *testing.T) {
|
||||||
|
want := &event.MessageMetadata{
|
||||||
|
Mailbox: "mb1",
|
||||||
|
ID: "id1",
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||||
|
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||||
|
Subject: "subj1",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(msg, "msg should not be nil")
|
||||||
|
|
||||||
|
function assert_eq(got, want)
|
||||||
|
assert(got == want, string.format("got name %q, wanted %q", got, want))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_eq(msg.mailbox, "mb1")
|
||||||
|
assert_eq(msg.id, "id1")
|
||||||
|
assert_eq(msg.subject, "subj1")
|
||||||
|
assert_eq(msg.size, 42)
|
||||||
|
|
||||||
|
assert_eq(msg.from.name, "name1")
|
||||||
|
assert_eq(msg.from.address, "addr1")
|
||||||
|
|
||||||
|
assert_eq(table.getn(msg.to), 1)
|
||||||
|
assert_eq(msg.to[1].name, "name2")
|
||||||
|
assert_eq(msg.to[1].address, "addr2")
|
||||||
|
|
||||||
|
assert_eq(msg.date, 981173106)
|
||||||
|
`
|
||||||
|
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerMessageMetadataType(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageMetadataSetters(t *testing.T) {
|
||||||
|
want := &event.MessageMetadata{
|
||||||
|
Mailbox: "mb1",
|
||||||
|
ID: "id1",
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||||
|
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||||
|
Subject: "subj1",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(msg, "msg should not be nil")
|
||||||
|
|
||||||
|
msg.mailbox = "mb1"
|
||||||
|
msg.id = "id1"
|
||||||
|
msg.subject = "subj1"
|
||||||
|
msg.size = 42
|
||||||
|
|
||||||
|
msg.from = address.new("name1", "addr1")
|
||||||
|
msg.to = { address.new("name2", "addr2") }
|
||||||
|
|
||||||
|
msg.date = 981173106
|
||||||
|
`
|
||||||
|
|
||||||
|
got := &event.MessageMetadata{}
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerMessageMetadataType(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
|
||||||
|
// Timezones will cause a naive comparison to fail.
|
||||||
|
assert.Equal(t, want.Date.Unix(), got.Date.Unix())
|
||||||
|
now := time.Now()
|
||||||
|
want.Date = now
|
||||||
|
got.Date = now
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const policyName = "policy"
|
||||||
|
|
||||||
|
func registerPolicyType(ls *lua.LState) {
|
||||||
|
mt := ls.NewTypeMetatable(policyName)
|
||||||
|
ls.SetGlobal(policyName, mt)
|
||||||
|
|
||||||
|
// Static attributes.
|
||||||
|
ls.SetField(mt, "allow", lua.LTrue)
|
||||||
|
ls.SetField(mt, "deny", lua.LFalse)
|
||||||
|
ls.SetField(mt, "defer", lua.LNil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"github.com/yuin/gopher-lua/parse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Host of Lua extensions.
|
||||||
|
type Host struct {
|
||||||
|
extHost *extension.Host
|
||||||
|
pool *statePool
|
||||||
|
logContext zerolog.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new Lua Host, pre-compiling the source.
|
||||||
|
func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
|
||||||
|
scriptPath := conf.Path
|
||||||
|
if scriptPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logContext := log.With().Str("module", "lua")
|
||||||
|
logger := logContext.Str("phase", "startup").Str("path", scriptPath).Logger()
|
||||||
|
|
||||||
|
// Pre-load, parse, and compile script.
|
||||||
|
if fi, err := os.Stat(scriptPath); err != nil {
|
||||||
|
logger.Info().Msg("Script file not found")
|
||||||
|
return nil, nil
|
||||||
|
} else if fi.IsDir() {
|
||||||
|
return nil, fmt.Errorf("Lua script %v is a directory", scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info().Msg("Loading script")
|
||||||
|
file, err := os.Open(scriptPath)
|
||||||
|
defer file.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewFromReader(extHost, bufio.NewReader(file), scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromReader constructs a new Lua Host, loading Lua source from the provided reader.
|
||||||
|
// The provided path is used in logging and error messages.
|
||||||
|
func NewFromReader(extHost *extension.Host, r io.Reader, path string) (*Host, error) {
|
||||||
|
logContext := log.With().Str("module", "lua")
|
||||||
|
logger := logContext.Str("phase", "startup").Str("path", path).Logger()
|
||||||
|
|
||||||
|
// Pre-parse, and compile script.
|
||||||
|
chunk, err := parse.Parse(r, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proto, err := lua.Compile(chunk, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the pool and confirm LState is retrievable.
|
||||||
|
pool := newStatePool(proto)
|
||||||
|
h := &Host{extHost: extHost, pool: pool, logContext: logContext}
|
||||||
|
if ls, err := pool.getState(); err == nil {
|
||||||
|
h.wireFunctions(logger, ls)
|
||||||
|
|
||||||
|
// State creation works, put it back.
|
||||||
|
pool.putState(ls)
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChannel creates a channel and places it into the named global variable
|
||||||
|
// in newly created LStates.
|
||||||
|
func (h *Host) CreateChannel(name string) chan lua.LValue {
|
||||||
|
return h.pool.createChannel(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detects global lua event listener functions and wires them up.
|
||||||
|
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
|
||||||
|
ib, err := getInbucket(ls)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to get inbucket global")
|
||||||
|
}
|
||||||
|
|
||||||
|
events := h.extHost.Events
|
||||||
|
const listenerName string = "lua"
|
||||||
|
|
||||||
|
if ib.After.MessageDeleted != nil {
|
||||||
|
events.AfterMessageDeleted.AddListener(listenerName, h.handleAfterMessageDeleted)
|
||||||
|
}
|
||||||
|
if ib.After.MessageStored != nil {
|
||||||
|
events.AfterMessageStored.AddListener(listenerName, h.handleAfterMessageStored)
|
||||||
|
}
|
||||||
|
if ib.Before.MailAccepted != nil {
|
||||||
|
events.BeforeMailAccepted.AddListener(listenerName, h.handleBeforeMailAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) {
|
||||||
|
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_deleted")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.pool.putState(ls)
|
||||||
|
|
||||||
|
// Call lua function.
|
||||||
|
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||||
|
if err := ls.CallByParam(
|
||||||
|
lua.P{Fn: ib.After.MessageDeleted, NRet: 0, Protect: true},
|
||||||
|
wrapMessageMetadata(ls, &msg),
|
||||||
|
); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) handleAfterMessageStored(msg event.MessageMetadata) {
|
||||||
|
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.pool.putState(ls)
|
||||||
|
|
||||||
|
// Call lua function.
|
||||||
|
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||||
|
if err := ls.CallByParam(
|
||||||
|
lua.P{Fn: ib.After.MessageStored, NRet: 0, Protect: true},
|
||||||
|
wrapMessageMetadata(ls, &msg),
|
||||||
|
); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *bool {
|
||||||
|
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer h.pool.putState(ls)
|
||||||
|
|
||||||
|
logger.Debug().Msgf("Calling Lua function with %+v", addr)
|
||||||
|
if err := ls.CallByParam(
|
||||||
|
lua.P{Fn: ib.Before.MailAccepted, NRet: 1, Protect: true},
|
||||||
|
lua.LString(addr.Local),
|
||||||
|
lua.LString(addr.Domain),
|
||||||
|
); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lval := ls.Get(1)
|
||||||
|
ls.Pop(1)
|
||||||
|
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
|
||||||
|
|
||||||
|
if lval.Type() == lua.LTNil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := true
|
||||||
|
if lua.LVIsFalse(lval) {
|
||||||
|
result = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common preparation for calling Lua functions.
|
||||||
|
func (h *Host) prepareInbucketFuncCall(funcName string) (logger zerolog.Logger, ls *lua.LState, ib *Inbucket, ok bool) {
|
||||||
|
logger = h.logContext.Str("event", funcName).Logger()
|
||||||
|
|
||||||
|
ls, err := h.pool.getState()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to get Lua state instance from pool")
|
||||||
|
return logger, nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ib, err = getInbucket(ls)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to obtain Lua inbucket object")
|
||||||
|
return logger, nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger, ls, ib, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package luahost_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/luahost"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LuaInit holds useful test globals.
|
||||||
|
const LuaInit = `
|
||||||
|
async = false
|
||||||
|
test_ok = true
|
||||||
|
|
||||||
|
-- Sends marks tests failed instead of erroring when enabled.
|
||||||
|
function assert_async(value, message)
|
||||||
|
if not value then
|
||||||
|
if async then
|
||||||
|
print(message)
|
||||||
|
test_ok = false
|
||||||
|
else
|
||||||
|
error(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Tests plain values and list-style tables.
|
||||||
|
function assert_eq(got, want)
|
||||||
|
if type(got) == "table" and type(want) == "table" then
|
||||||
|
assert_async(#got == #want, string.format("got %d elements, wanted %d", #got, #want))
|
||||||
|
|
||||||
|
for i, gotv in ipairs(got) do
|
||||||
|
local wantv = want[i]
|
||||||
|
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
|
||||||
|
end
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_async(got == want, string.format("got %q, wanted %q", got, want))
|
||||||
|
end
|
||||||
|
|
||||||
|
function assert_contains(got, want)
|
||||||
|
assert_async(string.find(got, want),
|
||||||
|
string.format("got %q, wanted it to contain %q", got, want))
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestEmptyScript(t *testing.T) {
|
||||||
|
script := ""
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
|
||||||
|
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAfterMessageDeleted(t *testing.T) {
|
||||||
|
// Register lua event listener, setup notify channel.
|
||||||
|
script := `
|
||||||
|
async = true
|
||||||
|
|
||||||
|
function inbucket.after.message_deleted(msg)
|
||||||
|
-- Full message bindings tested elsewhere.
|
||||||
|
assert_eq(msg.mailbox, "mb1")
|
||||||
|
assert_eq(msg.id, "id1")
|
||||||
|
notify:send(test_ok)
|
||||||
|
end
|
||||||
|
`
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
notify := luaHost.CreateChannel("notify")
|
||||||
|
|
||||||
|
// Send event, check channel response is true.
|
||||||
|
msg := &event.MessageMetadata{
|
||||||
|
Mailbox: "mb1",
|
||||||
|
ID: "id1",
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||||
|
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||||
|
Subject: "subj1",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
extHost.Events.AfterMessageDeleted.Emit(msg)
|
||||||
|
assertNotified(t, notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAfterMessageStored(t *testing.T) {
|
||||||
|
// Register lua event listener, setup notify channel.
|
||||||
|
script := `
|
||||||
|
async = true
|
||||||
|
|
||||||
|
function inbucket.after.message_stored(msg)
|
||||||
|
-- Full message bindings tested elsewhere.
|
||||||
|
assert_eq(msg.mailbox, "mb1")
|
||||||
|
assert_eq(msg.id, "id1")
|
||||||
|
notify:send(test_ok)
|
||||||
|
end
|
||||||
|
`
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
notify := luaHost.CreateChannel("notify")
|
||||||
|
|
||||||
|
// Send event, check channel response is true.
|
||||||
|
msg := &event.MessageMetadata{
|
||||||
|
Mailbox: "mb1",
|
||||||
|
ID: "id1",
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||||
|
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||||
|
Subject: "subj1",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
extHost.Events.AfterMessageStored.Emit(msg)
|
||||||
|
assertNotified(t, notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeforeMailAccepted(t *testing.T) {
|
||||||
|
// Register lua event listener.
|
||||||
|
script := `
|
||||||
|
function inbucket.before.mail_accepted(localpart, domain)
|
||||||
|
return localpart == "from" and domain == "test"
|
||||||
|
end
|
||||||
|
`
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Send event to be accepted.
|
||||||
|
addr := &event.AddressParts{Local: "from", Domain: "test"}
|
||||||
|
got := extHost.Events.BeforeMailAccepted.Emit(addr)
|
||||||
|
want := true
|
||||||
|
require.NotNil(t, got)
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send event to be denied.
|
||||||
|
addr = &event.AddressParts{Local: "reject", Domain: "me"}
|
||||||
|
got = extHost.Events.BeforeMailAccepted.Emit(addr)
|
||||||
|
want = false
|
||||||
|
require.NotNil(t, got)
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNotified(t *testing.T, notify chan lua.LValue) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case reslv := <-notify:
|
||||||
|
// Lua function received event.
|
||||||
|
if lua.LVIsFalse(reslv) {
|
||||||
|
t.Error("Lua responsed with false, wanted true")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Lua did not respond to event within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/cjoudrey/gluahttp"
|
||||||
|
"github.com/inbucket/gopher-json"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statePool struct {
|
||||||
|
sync.Mutex
|
||||||
|
funcProto *lua.FunctionProto // Compiled lua.
|
||||||
|
states []*lua.LState // Pool of available LStates.
|
||||||
|
channels map[string]chan lua.LValue // Global interop channels.
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStatePool(funcProto *lua.FunctionProto) *statePool {
|
||||||
|
return &statePool{
|
||||||
|
funcProto: funcProto,
|
||||||
|
channels: make(map[string]chan lua.LValue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newState creates a new LState and configures it. Lock must be held.
|
||||||
|
func (lp *statePool) newState() (*lua.LState, error) {
|
||||||
|
ls := lua.NewState()
|
||||||
|
|
||||||
|
// Load supplemental native modules.
|
||||||
|
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
|
||||||
|
ls.PreloadModule("json", json.Loader)
|
||||||
|
|
||||||
|
// Setup channels.
|
||||||
|
for name, ch := range lp.channels {
|
||||||
|
ls.SetGlobal(name, lua.LChannel(ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom types.
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
registerMessageMetadataType(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
registerPolicyType(ls)
|
||||||
|
|
||||||
|
// Run compiled script.
|
||||||
|
ls.Push(ls.NewFunctionFromProto(lp.funcProto))
|
||||||
|
if err := ls.PCall(0, lua.MultRet, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getState returns a free LState, or creates a new one.
|
||||||
|
func (lp *statePool) getState() (*lua.LState, error) {
|
||||||
|
lp.Lock()
|
||||||
|
defer lp.Unlock()
|
||||||
|
|
||||||
|
ln := len(lp.states)
|
||||||
|
if ln == 0 {
|
||||||
|
return lp.newState()
|
||||||
|
}
|
||||||
|
|
||||||
|
state := lp.states[ln-1]
|
||||||
|
lp.states = lp.states[0 : ln-1]
|
||||||
|
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// putState returns the LState to the pool.
|
||||||
|
func (lp *statePool) putState(state *lua.LState) {
|
||||||
|
if state.IsClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stack.
|
||||||
|
state.Pop(state.GetTop())
|
||||||
|
|
||||||
|
lp.Lock()
|
||||||
|
defer lp.Unlock()
|
||||||
|
|
||||||
|
lp.states = append(lp.states, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createChannel creates a new channel, which will become a global variable in
|
||||||
|
// newly created LStates. We also destroy any pooled states.
|
||||||
|
//
|
||||||
|
// Warning: There may still be checked out LStates that will not have the value
|
||||||
|
// set, which could be put back into the pool.
|
||||||
|
func (lp *statePool) createChannel(name string) chan lua.LValue {
|
||||||
|
lp.Lock()
|
||||||
|
defer lp.Unlock()
|
||||||
|
|
||||||
|
ch := make(chan lua.LValue, 10)
|
||||||
|
lp.channels[name] = ch
|
||||||
|
|
||||||
|
// Flush state pool.
|
||||||
|
for _, s := range lp.states {
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
lp.states = lp.states[:0]
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"github.com/yuin/gopher-lua/parse"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeEmptyPool() *statePool {
|
||||||
|
source := strings.NewReader("-- Empty source")
|
||||||
|
|
||||||
|
chunk, err := parse.Parse(source, "from string")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proto, err := lua.Compile(chunk, "from string")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStatePool(proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolGetsDistinct(t *testing.T) {
|
||||||
|
pool := makeEmptyPool()
|
||||||
|
|
||||||
|
a, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
b, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if a == b {
|
||||||
|
t.Error("Got pool a == b, expected distinct pools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolGrowsWithPuts(t *testing.T) {
|
||||||
|
pool := makeEmptyPool()
|
||||||
|
|
||||||
|
a, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
b, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||||
|
|
||||||
|
pool.putState(a)
|
||||||
|
pool.putState(b)
|
||||||
|
|
||||||
|
want := 2
|
||||||
|
if got := len(pool.states); got != want {
|
||||||
|
t.Errorf("len pool.states got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed LStates should not be added to the pool.
|
||||||
|
func TestPoolPutDiscardsClosed(t *testing.T) {
|
||||||
|
pool := makeEmptyPool()
|
||||||
|
|
||||||
|
a, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||||
|
|
||||||
|
a.Close()
|
||||||
|
pool.putState(a)
|
||||||
|
assert.Equal(t, 0, len(pool.states), "Wanted pool to remain empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolPutClearsStack(t *testing.T) {
|
||||||
|
pool := makeEmptyPool()
|
||||||
|
|
||||||
|
ls, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||||
|
|
||||||
|
// Setup stack.
|
||||||
|
ls.Push(lua.LNumber(4))
|
||||||
|
ls.Push(lua.LString("bacon"))
|
||||||
|
require.Equal(t, 2, ls.GetTop(), "Want stack to have two items")
|
||||||
|
|
||||||
|
// Return and verify stack cleared.
|
||||||
|
pool.putState(ls)
|
||||||
|
assert.Equal(t, 1, len(pool.states), "Wanted pool to have one item")
|
||||||
|
require.Equal(t, 0, ls.GetTop(), "Want stack to be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolSetsChannels(t *testing.T) {
|
||||||
|
pool := makeEmptyPool()
|
||||||
|
pool.createChannel("test_chan")
|
||||||
|
|
||||||
|
s, err := pool.getState()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := s.GetGlobal("test_chan")
|
||||||
|
assert.Equal(t, lua.LTChannel, got.Type(),
|
||||||
|
"Got global type %v, wanted LTChannel", got.Type().String())
|
||||||
|
}
|
||||||
+20
-26
@@ -7,10 +7,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -24,7 +24,7 @@ type Manager interface {
|
|||||||
prefix string,
|
prefix string,
|
||||||
content []byte,
|
content []byte,
|
||||||
) (id string, err error)
|
) (id string, err error)
|
||||||
GetMetadata(mailbox string) ([]*Metadata, error)
|
GetMetadata(mailbox string) ([]*event.MessageMetadata, error)
|
||||||
GetMessage(mailbox, id string) (*Message, error)
|
GetMessage(mailbox, id string) (*Message, error)
|
||||||
MarkSeen(mailbox, id string) error
|
MarkSeen(mailbox, id string) error
|
||||||
PurgeMessages(mailbox string) error
|
PurgeMessages(mailbox string) error
|
||||||
@@ -37,7 +37,7 @@ type Manager interface {
|
|||||||
type StoreManager struct {
|
type StoreManager struct {
|
||||||
AddrPolicy *policy.Addressing
|
AddrPolicy *policy.Addressing
|
||||||
Store storage.Store
|
Store storage.Store
|
||||||
Hub *msghub.Hub
|
ExtHost *extension.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver submits a new message to the store.
|
// Deliver submits a new message to the store.
|
||||||
@@ -65,9 +65,10 @@ func (s *StoreManager) Deliver(
|
|||||||
toaddr[i] = &torecip.Address
|
toaddr[i] = &torecip.Address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
||||||
delivery := &Delivery{
|
delivery := &Delivery{
|
||||||
Meta: Metadata{
|
Meta: event.MessageMetadata{
|
||||||
Mailbox: to.Mailbox,
|
Mailbox: to.Mailbox,
|
||||||
From: fromaddr[0],
|
From: fromaddr[0],
|
||||||
To: toaddr,
|
To: toaddr,
|
||||||
@@ -80,31 +81,24 @@ func (s *StoreManager) Deliver(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if s.Hub != nil {
|
|
||||||
// Broadcast message information.
|
// Emit message stored event.
|
||||||
broadcast := msghub.Message{
|
event := delivery.Meta
|
||||||
Mailbox: to.Mailbox,
|
event.ID = id
|
||||||
ID: id,
|
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||||
From: stringutil.StringAddress(delivery.From()),
|
|
||||||
To: stringutil.StringAddressList(delivery.To()),
|
|
||||||
Subject: delivery.Subject(),
|
|
||||||
Date: delivery.Date(),
|
|
||||||
Size: delivery.Size(),
|
|
||||||
}
|
|
||||||
s.Hub.Dispatch(broadcast)
|
|
||||||
}
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata returns a slice of metadata for the specified mailbox.
|
// GetMetadata returns a slice of metadata for the specified mailbox.
|
||||||
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) {
|
func (s *StoreManager) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
|
||||||
messages, err := s.Store.GetMessages(mailbox)
|
messages, err := s.Store.GetMessages(mailbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metas := make([]*Metadata, len(messages))
|
metas := make([]*event.MessageMetadata, len(messages))
|
||||||
for i, sm := range messages {
|
for i, sm := range messages {
|
||||||
metas[i] = makeMetadata(sm)
|
metas[i] = MakeMetadata(sm)
|
||||||
}
|
}
|
||||||
return metas, nil
|
return metas, nil
|
||||||
}
|
}
|
||||||
@@ -124,8 +118,8 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = r.Close()
|
_ = r.Close()
|
||||||
header := makeMetadata(sm)
|
header := MakeMetadata(sm)
|
||||||
return &Message{Metadata: *header, env: env}, nil
|
return &Message{MessageMetadata: *header, env: env}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSeen marks the message as having been read.
|
// MarkSeen marks the message as having been read.
|
||||||
@@ -159,9 +153,9 @@ func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
|
|||||||
return s.AddrPolicy.ExtractMailbox(mailbox)
|
return s.AddrPolicy.ExtractMailbox(mailbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeMetadata populates Metadata from a storage.Message.
|
// MakeMetadata populates Metadata from a storage.Message.
|
||||||
func makeMetadata(m storage.Message) *Metadata {
|
func MakeMetadata(m storage.Message) *event.MessageMetadata {
|
||||||
return &Metadata{
|
return &event.MessageMetadata{
|
||||||
Mailbox: m.Mailbox(),
|
Mailbox: m.Mailbox(),
|
||||||
ID: m.ID(),
|
ID: m.ID(),
|
||||||
From: m.From(),
|
From: m.From(),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package message_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerEmitsMessageStoredEvent(t *testing.T) {
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
sm := &message.StoreManager{
|
||||||
|
AddrPolicy: &policy.Addressing{},
|
||||||
|
Store: test.NewStore(),
|
||||||
|
ExtHost: extHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||||
|
|
||||||
|
// Attempt to deliver a message to generate event.
|
||||||
|
if _, err := sm.Deliver(
|
||||||
|
&policy.Recipient{},
|
||||||
|
"from@example.com",
|
||||||
|
[]*policy.Recipient{},
|
||||||
|
"prefix",
|
||||||
|
[]byte("From: from@example.com\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := listener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, got, "No event received, or it was nil")
|
||||||
|
}
|
||||||
+5
-16
@@ -8,32 +8,21 @@ import (
|
|||||||
"net/textproto"
|
"net/textproto"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata holds information about a message, but not the content.
|
|
||||||
type Metadata struct {
|
|
||||||
Mailbox string
|
|
||||||
ID string
|
|
||||||
From *mail.Address
|
|
||||||
To []*mail.Address
|
|
||||||
Date time.Time
|
|
||||||
Subject string
|
|
||||||
Size int64
|
|
||||||
Seen bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message holds both the metadata and content of a message.
|
// Message holds both the metadata and content of a message.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Metadata
|
event.MessageMetadata
|
||||||
env *enmime.Envelope
|
env *enmime.Envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a new Message
|
// New constructs a new Message
|
||||||
func New(m Metadata, e *enmime.Envelope) *Message {
|
func New(m event.MessageMetadata, e *enmime.Envelope) *Message {
|
||||||
return &Message{
|
return &Message{
|
||||||
Metadata: m,
|
MessageMetadata: m,
|
||||||
env: e,
|
env: e,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +54,7 @@ func (m *Message) Text() string {
|
|||||||
|
|
||||||
// Delivery is used to add a message to storage.
|
// Delivery is used to add a message to storage.
|
||||||
type Delivery struct {
|
type Delivery struct {
|
||||||
Meta Metadata
|
Meta event.MessageMetadata
|
||||||
Reader io.Reader
|
Reader io.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-25
@@ -3,26 +3,19 @@ package msghub
|
|||||||
import (
|
import (
|
||||||
"container/ring"
|
"container/ring"
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Length of msghub operation queue
|
// Length of msghub operation queue
|
||||||
const opChanLen = 100
|
const opChanLen = 100
|
||||||
|
|
||||||
// Message contains the basic header data for a message
|
|
||||||
type Message struct {
|
|
||||||
Mailbox string
|
|
||||||
ID string
|
|
||||||
From string
|
|
||||||
To []string
|
|
||||||
Subject string
|
|
||||||
Date time.Time
|
|
||||||
Size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listener receives the contents of the history buffer, followed by new messages
|
// Listener receives the contents of the history buffer, followed by new messages
|
||||||
type Listener interface {
|
type Listener interface {
|
||||||
Receive(msg Message) error
|
Receive(msg event.MessageMetadata) error
|
||||||
|
Delete(mailbox string, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub relays messages on to its listeners
|
// Hub relays messages on to its listeners
|
||||||
@@ -36,38 +29,51 @@ type Hub struct {
|
|||||||
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
|
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
|
||||||
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
|
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
|
||||||
// context is canceled.
|
// context is canceled.
|
||||||
func New(ctx context.Context, historyLen int) *Hub {
|
func New(historyLen int, extHost *extension.Host) *Hub {
|
||||||
h := &Hub{
|
hub := &Hub{
|
||||||
history: ring.New(historyLen),
|
history: ring.New(historyLen),
|
||||||
listeners: make(map[Listener]struct{}),
|
listeners: make(map[Listener]struct{}),
|
||||||
opChan: make(chan func(h *Hub), opChanLen),
|
opChan: make(chan func(h *Hub), opChanLen),
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
// Register an extension event listener for MessageStored.
|
||||||
|
extHost.Events.AfterMessageStored.AddListener("msghub",
|
||||||
|
func(msg event.MessageMetadata) {
|
||||||
|
hub.Dispatch(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
extHost.Events.AfterMessageDeleted.AddListener("msghub",
|
||||||
|
func(msg event.MessageMetadata) {
|
||||||
|
hub.Delete(msg.Mailbox, msg.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Hub processing loop.
|
||||||
|
func (hub *Hub) Start(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Shutdown
|
// Shutdown
|
||||||
close(h.opChan)
|
close(hub.opChan)
|
||||||
return
|
return
|
||||||
case op := <-h.opChan:
|
case op := <-hub.opChan:
|
||||||
op(h)
|
hub.runOp(op)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
|
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
|
||||||
// history buffer and then relayed to all registered listeners.
|
// history buffer and then relayed to all registered listeners.
|
||||||
func (hub *Hub) Dispatch(msg Message) {
|
func (hub *Hub) Dispatch(msg event.MessageMetadata) {
|
||||||
hub.opChan <- func(h *Hub) {
|
hub.opChan <- func(h *Hub) {
|
||||||
if h.history != nil {
|
if h.history != nil {
|
||||||
// Add to history buffer
|
// Add to history buffer
|
||||||
h.history.Value = msg
|
h.history.Value = msg
|
||||||
h.history = h.history.Next()
|
h.history = h.history.Next()
|
||||||
// Deliver message to all listeners, removing listeners if they return an error
|
|
||||||
|
// Relay event to all listeners, removing listeners if they return an error.
|
||||||
for l := range h.listeners {
|
for l := range h.listeners {
|
||||||
if err := l.Receive(msg); err != nil {
|
if err := l.Receive(msg); err != nil {
|
||||||
delete(h.listeners, l)
|
delete(h.listeners, l)
|
||||||
@@ -77,13 +83,44 @@ func (hub *Hub) Dispatch(msg Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the message from the history buffer and instructs listeners to do the same.
|
||||||
|
func (hub *Hub) Delete(mailbox string, id string) {
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
if h.history == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate and remove history entry.
|
||||||
|
p := h.history
|
||||||
|
end := p
|
||||||
|
for {
|
||||||
|
if next, ok := p.Next().Value.(event.MessageMetadata); ok {
|
||||||
|
if mailbox == next.Mailbox && id == next.ID {
|
||||||
|
p.Next().Value = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p = p.Next(); p == end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay event to all listeners, removing listeners if they return an error.
|
||||||
|
for l := range h.listeners {
|
||||||
|
if err := l.Delete(mailbox, id); err != nil {
|
||||||
|
delete(h.listeners, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AddListener registers a listener to receive broadcasted messages.
|
// AddListener registers a listener to receive broadcasted messages.
|
||||||
func (hub *Hub) AddListener(l Listener) {
|
func (hub *Hub) AddListener(l Listener) {
|
||||||
hub.opChan <- func(h *Hub) {
|
hub.opChan <- func(h *Hub) {
|
||||||
// Playback log
|
// Playback log
|
||||||
h.history.Do(func(v interface{}) {
|
h.history.Do(func(v interface{}) {
|
||||||
if v != nil {
|
if v != nil {
|
||||||
l.Receive(v.(Message))
|
l.Receive(v.(event.MessageMetadata))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,3 +145,17 @@ func (hub *Hub) Sync() {
|
|||||||
}
|
}
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (hub *Hub) runOp(op func(*Hub)) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if err, ok := r.(error); ok {
|
||||||
|
log.Error().Str("module", "msghub").Err(err).Msg("Operation panicked")
|
||||||
|
} else {
|
||||||
|
log.Error().Str("module", "msghub").Err(err).Msgf("Operation panicked: %s", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
op(hub)
|
||||||
|
}
|
||||||
|
|||||||
+157
-33
@@ -3,15 +3,23 @@ package msghub
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testListener implements the Listener interface, mock for unit tests
|
// testListener implements the Listener interface, mock for unit tests
|
||||||
type testListener struct {
|
type testListener struct {
|
||||||
messages []*Message // received messages
|
messages []*event.MessageMetadata // received messages
|
||||||
wantMessages int // how many messages this listener wants to receive
|
deletes []string // received deletes
|
||||||
errorAfter int // when != 0, messages until Receive() begins returning error
|
wantEvents int // how many events this listener wants to receive
|
||||||
|
errorAfter int // when != 0, event count until Receive() begins returning error
|
||||||
|
gotEvents int
|
||||||
|
|
||||||
done chan struct{} // closed once we have received wantMessages
|
done chan struct{} // closed once we have received wantMessages
|
||||||
overflow chan struct{} // closed if we receive wantMessages+1
|
overflow chan struct{} // closed if we receive wantMessages+1
|
||||||
@@ -19,8 +27,9 @@ type testListener struct {
|
|||||||
|
|
||||||
func newTestListener(want int) *testListener {
|
func newTestListener(want int) *testListener {
|
||||||
l := &testListener{
|
l := &testListener{
|
||||||
messages: make([]*Message, 0, want*2),
|
messages: make([]*event.MessageMetadata, 0, want*2),
|
||||||
wantMessages: want,
|
deletes: make([]string, 0, want*2),
|
||||||
|
wantEvents: want,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
overflow: make(chan struct{}),
|
overflow: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -32,29 +41,34 @@ func newTestListener(want int) *testListener {
|
|||||||
|
|
||||||
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
||||||
// if instructed
|
// if instructed
|
||||||
func (l *testListener) Receive(msg Message) error {
|
func (l *testListener) Receive(msg event.MessageMetadata) error {
|
||||||
|
l.gotEvents++
|
||||||
l.messages = append(l.messages, &msg)
|
l.messages = append(l.messages, &msg)
|
||||||
if len(l.messages) == l.wantMessages {
|
if l.gotEvents == l.wantEvents {
|
||||||
close(l.done)
|
close(l.done)
|
||||||
}
|
}
|
||||||
if len(l.messages) == l.wantMessages+1 {
|
if l.gotEvents == l.wantEvents+1 {
|
||||||
close(l.overflow)
|
close(l.overflow)
|
||||||
}
|
}
|
||||||
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
|
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
|
||||||
return fmt.Errorf("Too many messages")
|
return fmt.Errorf("Too many messages")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *testListener) Delete(mailbox string, id string) error {
|
||||||
|
l.gotEvents++
|
||||||
|
l.deletes = append(l.deletes, mailbox+"/"+id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// String formats the got vs wanted message counts
|
// String formats the got vs wanted message counts
|
||||||
func (l *testListener) String() string {
|
func (l *testListener) String() string {
|
||||||
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
|
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHubNew(t *testing.T) {
|
func TestHubNew(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
hub := New(5, extension.NewHost())
|
||||||
defer cancel()
|
|
||||||
hub := New(ctx, 5)
|
|
||||||
if hub == nil {
|
if hub == nil {
|
||||||
t.Fatal("New() == nil, expected a new Hub")
|
t.Fatal("New() == nil, expected a new Hub")
|
||||||
}
|
}
|
||||||
@@ -63,30 +77,33 @@ func TestHubNew(t *testing.T) {
|
|||||||
func TestHubZeroLen(t *testing.T) {
|
func TestHubZeroLen(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 0)
|
hub := New(0, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
hub.Dispatch(m)
|
hub.Dispatch(m)
|
||||||
}
|
}
|
||||||
// Just making sure Hub doesn't panic
|
// Ensures Hub doesn't panic
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHubZeroListeners(t *testing.T) {
|
func TestHubZeroListeners(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
hub.Dispatch(m)
|
hub.Dispatch(m)
|
||||||
}
|
}
|
||||||
// Just making sure Hub doesn't panic
|
// Ensures Hub doesn't panic
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHubOneListener(t *testing.T) {
|
func TestHubOneListener(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
l := newTestListener(1)
|
l := newTestListener(1)
|
||||||
|
|
||||||
hub.AddListener(l)
|
hub.AddListener(l)
|
||||||
@@ -103,8 +120,9 @@ func TestHubOneListener(t *testing.T) {
|
|||||||
func TestHubRemoveListener(t *testing.T) {
|
func TestHubRemoveListener(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
l := newTestListener(1)
|
l := newTestListener(1)
|
||||||
|
|
||||||
hub.AddListener(l)
|
hub.AddListener(l)
|
||||||
@@ -125,8 +143,9 @@ func TestHubRemoveListener(t *testing.T) {
|
|||||||
func TestHubRemoveListenerOnError(t *testing.T) {
|
func TestHubRemoveListenerOnError(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
|
|
||||||
// error after 1 means listener should receive 2 messages before being removed
|
// error after 1 means listener should receive 2 messages before being removed
|
||||||
l := newTestListener(2)
|
l := newTestListener(2)
|
||||||
@@ -151,14 +170,15 @@ func TestHubRemoveListenerOnError(t *testing.T) {
|
|||||||
func TestHubHistoryReplay(t *testing.T) {
|
func TestHubHistoryReplay(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 100)
|
hub := New(100, extension.NewHost())
|
||||||
|
go hub.Start(ctx)
|
||||||
l1 := newTestListener(3)
|
l1 := newTestListener(3)
|
||||||
hub.AddListener(l1)
|
hub.AddListener(l1)
|
||||||
|
|
||||||
// Broadcast 3 messages with no listeners
|
// Broadcast 3 messages with no listeners
|
||||||
msgs := make([]Message, 3)
|
msgs := make([]event.MessageMetadata, 3)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := 0; i < len(msgs); i++ {
|
||||||
msgs[i] = Message{
|
msgs[i] = event.MessageMetadata{
|
||||||
Subject: fmt.Sprintf("subj %v", i),
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
}
|
}
|
||||||
hub.Dispatch(msgs[i])
|
hub.Dispatch(msgs[i])
|
||||||
@@ -191,17 +211,67 @@ func TestHubHistoryReplay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHubHistoryDelete(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(100, extension.NewHost())
|
||||||
|
go hub.Start(ctx)
|
||||||
|
l1 := newTestListener(3)
|
||||||
|
hub.AddListener(l1)
|
||||||
|
|
||||||
|
// Broadcast 3 messages with no listeners
|
||||||
|
msgs := make([]event.MessageMetadata, 3)
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = event.MessageMetadata{
|
||||||
|
Mailbox: "hub",
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for messages (live)
|
||||||
|
select {
|
||||||
|
case <-l1.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l1)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Delete("hub", "1") // Delete a message
|
||||||
|
hub.Delete("zzz", "0") // Attempt to delete non-existent mailbox message
|
||||||
|
|
||||||
|
// Add a new listener, waits for 2 messages
|
||||||
|
l2 := newTestListener(2)
|
||||||
|
hub.AddListener(l2)
|
||||||
|
|
||||||
|
// Wait for messages (history)
|
||||||
|
select {
|
||||||
|
case <-l2.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l2)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"subj 0", "subj 2"}
|
||||||
|
for i := 0; i < len(want); i++ {
|
||||||
|
got := l2.messages[i].Subject
|
||||||
|
if got != want[i] {
|
||||||
|
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHubHistoryReplayWrap(t *testing.T) {
|
func TestHubHistoryReplayWrap(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
|
go hub.Start(ctx)
|
||||||
l1 := newTestListener(20)
|
l1 := newTestListener(20)
|
||||||
hub.AddListener(l1)
|
hub.AddListener(l1)
|
||||||
|
|
||||||
// Broadcast more messages than the hub can hold
|
// Broadcast more messages than the hub can hold
|
||||||
msgs := make([]Message, 20)
|
msgs := make([]event.MessageMetadata, 20)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := 0; i < len(msgs); i++ {
|
||||||
msgs[i] = Message{
|
msgs[i] = event.MessageMetadata{
|
||||||
Subject: fmt.Sprintf("subj %v", i),
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
}
|
}
|
||||||
hub.Dispatch(msgs[i])
|
hub.Dispatch(msgs[i])
|
||||||
@@ -234,10 +304,64 @@ func TestHubHistoryReplayWrap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
|
||||||
|
bufferSize := 5
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(bufferSize, extension.NewHost())
|
||||||
|
go hub.Start(ctx)
|
||||||
|
|
||||||
|
waitForMessages := func(n int) {
|
||||||
|
l := newTestListener(n)
|
||||||
|
hub.AddListener(l)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-l.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast more messages than the hub can hold.
|
||||||
|
msgs := make([]event.MessageMetadata, 10)
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = event.MessageMetadata{
|
||||||
|
Mailbox: "first",
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
waitForMessages(bufferSize)
|
||||||
|
|
||||||
|
// Buffer must be configured size.
|
||||||
|
require.Equal(t, bufferSize, hub.history.Len())
|
||||||
|
|
||||||
|
// Delete a message still present in buffer.
|
||||||
|
hub.Delete("first", "7")
|
||||||
|
|
||||||
|
// Broadcast another set of messages.
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = event.MessageMetadata{
|
||||||
|
Mailbox: "second",
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
waitForMessages(bufferSize)
|
||||||
|
|
||||||
|
// Ensure the buffer did not shrink after delete.
|
||||||
|
got := hub.history.Len()
|
||||||
|
assert.Equal(t, bufferSize, got, "got buffer size %d, wanted %d", got, bufferSize)
|
||||||
|
}
|
||||||
|
|
||||||
func TestHubContextCancel(t *testing.T) {
|
func TestHubContextCancel(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
hub := New(ctx, 5)
|
hub := New(5, extension.NewHost())
|
||||||
m := Message{}
|
go hub.Start(ctx)
|
||||||
|
m := event.MessageMetadata{}
|
||||||
l := newTestListener(1)
|
l := newTestListener(1)
|
||||||
|
|
||||||
hub.AddListener(l)
|
hub.AddListener(l)
|
||||||
|
|||||||
+76
-12
@@ -3,6 +3,7 @@ package policy
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,30 +18,41 @@ type Addressing struct {
|
|||||||
|
|
||||||
// ExtractMailbox extracts the mailbox name from a partial email address.
|
// ExtractMailbox extracts the mailbox name from a partial email address.
|
||||||
func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
||||||
|
if a.Config.MailboxNaming == config.DomainNaming {
|
||||||
|
return extractDomainMailbox(address)
|
||||||
|
}
|
||||||
|
|
||||||
local, domain, err := parseEmailAddress(address)
|
local, domain, err := parseEmailAddress(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
local, err = parseMailboxName(local)
|
local, err = parseMailboxName(local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Config.MailboxNaming == config.LocalNaming {
|
if a.Config.MailboxNaming == config.LocalNaming {
|
||||||
return local, nil
|
return local, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Config.MailboxNaming != config.FullNaming {
|
if a.Config.MailboxNaming != config.FullNaming {
|
||||||
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
||||||
}
|
}
|
||||||
|
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return local, nil
|
return local, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ValidateDomainPart(domain) {
|
if !ValidateDomainPart(domain) {
|
||||||
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
}
|
}
|
||||||
|
|
||||||
return local + "@" + domain, nil
|
return local + "@" + domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecipient parses an address into a Recipient.
|
// NewRecipient parses an address into a Recipient. This is used for parsing RCPT TO arguments,
|
||||||
|
// not To headers.
|
||||||
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
||||||
local, domain, err := ParseEmailAddress(address)
|
local, domain, err := ParseEmailAddress(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,12 +62,8 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ar, err := mail.ParseAddress(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Recipient{
|
return &Recipient{
|
||||||
Address: *ar,
|
Address: mail.Address{Address: address},
|
||||||
addrPolicy: a,
|
addrPolicy: a,
|
||||||
LocalPart: local,
|
LocalPart: local,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
@@ -108,13 +116,24 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
|
|||||||
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
|
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
|
||||||
// ParseEmailAddress().
|
// ParseEmailAddress().
|
||||||
func ValidateDomainPart(domain string) bool {
|
func ValidateDomainPart(domain string) bool {
|
||||||
if len(domain) == 0 {
|
ln := len(domain)
|
||||||
|
if ln == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if len(domain) > 255 {
|
if ln > 255 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if domain[len(domain)-1] != '.' {
|
if ln >= 4 && domain[0] == '[' && domain[ln-1] == ']' {
|
||||||
|
// Bracketed domains must contain an IP address.
|
||||||
|
s := 1
|
||||||
|
if strings.HasPrefix(domain[1:], "IPv6:") {
|
||||||
|
s = 6
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(domain[s : ln-1])
|
||||||
|
return ip != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain[ln-1] != '.' {
|
||||||
domain += "."
|
domain += "."
|
||||||
}
|
}
|
||||||
prev := '.'
|
prev := '.'
|
||||||
@@ -128,8 +147,8 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
hasAlphaNum = true
|
hasAlphaNum = true
|
||||||
labelLen++
|
labelLen++
|
||||||
case c == '-':
|
case c == '-':
|
||||||
if prev == '.' {
|
if prev == '.' || prev == '-' {
|
||||||
// Cannot lead with hyphen.
|
// Cannot lead with hyphen or double hyphen.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case c == '.':
|
case c == '.':
|
||||||
@@ -154,6 +173,40 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracts the mailbox name when domain addressing is enabled.
|
||||||
|
func extractDomainMailbox(address string) (string, error) {
|
||||||
|
var local, domain string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if address != "" && address[0] == '[' && address[len(address)-1] == ']' {
|
||||||
|
// Likely an IP address in brackets, treat as domain only.
|
||||||
|
domain = address
|
||||||
|
} else {
|
||||||
|
local, domain, err = parseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if local != "" {
|
||||||
|
local, err = parseMailboxName(local)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no @domain is specified, assume this is being used for mailbox lookup via the API.
|
||||||
|
if domain == "" {
|
||||||
|
domain = local
|
||||||
|
}
|
||||||
|
|
||||||
|
if ValidateDomainPart(domain) == false {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
|
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
|
||||||
// error is returned if the local part fails validation following the guidelines in RFC3696. The
|
// error is returned if the local part fails validation following the guidelines in RFC3696. The
|
||||||
// domain part is optional and not validated.
|
// domain part is optional and not validated.
|
||||||
@@ -164,12 +217,23 @@ func parseEmailAddress(address string) (local string, domain string, err error)
|
|||||||
if len(address) > 320 {
|
if len(address) > 320 {
|
||||||
return "", "", fmt.Errorf("address exceeds 320 characters")
|
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove forward-path routes.
|
||||||
if address[0] == '@' {
|
if address[0] == '@' {
|
||||||
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
end := strings.IndexRune(address, ':')
|
||||||
|
if end == -1 {
|
||||||
|
return "", "", fmt.Errorf("missing terminating ':' in route specification")
|
||||||
}
|
}
|
||||||
|
address = address[end+1:]
|
||||||
|
if address == "" {
|
||||||
|
return "", "", fmt.Errorf("Address empty after removing route specification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if address[0] == '.' {
|
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.
|
// Loop over address parsing out local part.
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
prev := byte('.')
|
prev := byte('.')
|
||||||
|
|||||||
@@ -125,111 +125,163 @@ func TestShouldStoreDomain(t *testing.T) {
|
|||||||
func TestExtractMailboxValid(t *testing.T) {
|
func TestExtractMailboxValid(t *testing.T) {
|
||||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
|
||||||
testTable := []struct {
|
testTable := []struct {
|
||||||
input string // Input to test
|
input string // Input to test
|
||||||
local string // Expected output when mailbox naming = local
|
local string // Expected output when mailbox naming = local
|
||||||
full string // Expected output when mailbox naming = full
|
full string // Expected output when mailbox naming = full
|
||||||
|
domain string // Expected output when mailbox naming = domain
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
input: "mailbox",
|
input: "mailbox",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox",
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user123",
|
input: "user123",
|
||||||
local: "user123",
|
local: "user123",
|
||||||
full: "user123",
|
full: "user123",
|
||||||
|
domain: "user123",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "MailBOX",
|
input: "MailBOX",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox",
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "First.Last",
|
input: "First.Last",
|
||||||
local: "first.last",
|
local: "first.last",
|
||||||
full: "first.last",
|
full: "first.last",
|
||||||
|
domain: "first.last",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user+label",
|
input: "user+label",
|
||||||
local: "user",
|
local: "user",
|
||||||
full: "user",
|
full: "user",
|
||||||
|
domain: "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars!#$%",
|
input: "chars!#$%",
|
||||||
local: "chars!#$%",
|
local: "chars!#$%",
|
||||||
full: "chars!#$%",
|
full: "chars!#$%",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars&'*-",
|
input: "chars&'*-",
|
||||||
local: "chars&'*-",
|
local: "chars&'*-",
|
||||||
full: "chars&'*-",
|
full: "chars&'*-",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars=/?^",
|
input: "chars=/?^",
|
||||||
local: "chars=/?^",
|
local: "chars=/?^",
|
||||||
full: "chars=/?^",
|
full: "chars=/?^",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars_`.{",
|
input: "chars_`.{",
|
||||||
local: "chars_`.{",
|
local: "chars_`.{",
|
||||||
full: "chars_`.{",
|
full: "chars_`.{",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars|}~",
|
input: "chars|}~",
|
||||||
local: "chars|}~",
|
local: "chars|}~",
|
||||||
full: "chars|}~",
|
full: "chars|}~",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "mailbox@domain.com",
|
input: "mailbox@domain.com",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox@domain.com",
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user123@domain.com",
|
input: "user123@domain.com",
|
||||||
local: "user123",
|
local: "user123",
|
||||||
full: "user123@domain.com",
|
full: "user123@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "MailBOX@domain.com",
|
input: "MailBOX@domain.com",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox@domain.com",
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "First.Last@domain.com",
|
input: "First.Last@domain.com",
|
||||||
local: "first.last",
|
local: "first.last",
|
||||||
full: "first.last@domain.com",
|
full: "first.last@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user+label@domain.com",
|
input: "user+label@domain.com",
|
||||||
local: "user",
|
local: "user",
|
||||||
full: "user@domain.com",
|
full: "user@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars!#$%@domain.com",
|
input: "chars!#$%@domain.com",
|
||||||
local: "chars!#$%",
|
local: "chars!#$%",
|
||||||
full: "chars!#$%@domain.com",
|
full: "chars!#$%@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars&'*-@domain.com",
|
input: "chars&'*-@domain.com",
|
||||||
local: "chars&'*-",
|
local: "chars&'*-",
|
||||||
full: "chars&'*-@domain.com",
|
full: "chars&'*-@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars=/?^@domain.com",
|
input: "chars=/?^@domain.com",
|
||||||
local: "chars=/?^",
|
local: "chars=/?^",
|
||||||
full: "chars=/?^@domain.com",
|
full: "chars=/?^@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars_`.{@domain.com",
|
input: "chars_`.{@domain.com",
|
||||||
local: "chars_`.{",
|
local: "chars_`.{",
|
||||||
full: "chars_`.{@domain.com",
|
full: "chars_`.{@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars|}~@domain.com",
|
input: "chars|}~@domain.com",
|
||||||
local: "chars|}~",
|
local: "chars|}~",
|
||||||
full: "chars|}~@domain.com",
|
full: "chars|}~@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~@example.co.uk",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~@example.co.uk",
|
||||||
|
domain: "example.co.uk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "@host:user+label@domain.com",
|
||||||
|
local: "user",
|
||||||
|
full: "user@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "@a.com,@b.com:user+label@domain.com",
|
||||||
|
local: "user",
|
||||||
|
full: "user@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "u@[127.0.0.1]",
|
||||||
|
local: "u",
|
||||||
|
full: "u@[127.0.0.1]",
|
||||||
|
domain: "[127.0.0.1]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "u@[IPv6:2001:db8:aaaa:1::100]",
|
||||||
|
local: "u",
|
||||||
|
full: "u@[IPv6:2001:db8:aaaa:1::100]",
|
||||||
|
domain: "[IPv6:2001:db8:aaaa:1::100]",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range testTable {
|
for _, tc := range testTable {
|
||||||
@@ -247,12 +299,52 @@ func TestExtractMailboxValid(t *testing.T) {
|
|||||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test special cases with domain addressing mode.
|
||||||
|
func TestExtractDomainMailboxValid(t *testing.T) {
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
input string // Input to test
|
||||||
|
domain string // Expected output when mailbox naming = domain
|
||||||
|
}{
|
||||||
|
"ipv4": {
|
||||||
|
input: "[127.0.0.1]",
|
||||||
|
domain: "[127.0.0.1]",
|
||||||
|
},
|
||||||
|
"medium ipv6": {
|
||||||
|
input: "[IPv6:2001:db8:aaaa:1::100]",
|
||||||
|
domain: "[IPv6:2001:db8:aaaa:1::100]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
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) {
|
func TestExtractMailboxInvalid(t *testing.T) {
|
||||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
|
||||||
// Test local mailbox naming policy.
|
// Test local mailbox naming policy.
|
||||||
localInvalidTable := []struct {
|
localInvalidTable := []struct {
|
||||||
input, msg string
|
input, msg string
|
||||||
@@ -267,6 +359,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
|
|||||||
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
|
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test full mailbox naming policy.
|
// Test full mailbox naming policy.
|
||||||
fullInvalidTable := []struct {
|
fullInvalidTable := []struct {
|
||||||
input, msg string
|
input, msg string
|
||||||
@@ -282,6 +375,29 @@ func TestExtractMailboxInvalid(t *testing.T) {
|
|||||||
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
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) {
|
func TestValidateDomain(t *testing.T) {
|
||||||
@@ -307,6 +423,10 @@ func TestValidateDomain(t *testing.T) {
|
|||||||
{strings.Repeat("a", 256), false, "Max domain length is 255"},
|
{strings.Repeat("a", 256), false, "Max domain length is 255"},
|
||||||
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
|
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
|
||||||
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
|
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
|
||||||
|
{"[0.0.0.0]", true, "Single digit octet IP addr is valid"},
|
||||||
|
{"[123.123.123.123]", true, "Multiple digit octet IP addr is valid"},
|
||||||
|
{"[IPv6:2001:0db8:aaaa:0001:0000:0000:0000:0200]", true, "Full IPv6 addr is valid"},
|
||||||
|
{"[IPv6:::1]", true, "Abbr IPv6 addr is valid"},
|
||||||
}
|
}
|
||||||
for _, tt := range testTable {
|
for _, tt := range testTable {
|
||||||
if policy.ValidateDomainPart(tt.input) != tt.expect {
|
if policy.ValidateDomainPart(tt.input) != tt.expect {
|
||||||
@@ -361,6 +481,9 @@ func TestValidateLocal(t *testing.T) {
|
|||||||
{"$A12345", true, "RFC3696 test case should be valid"},
|
{"$A12345", true, "RFC3696 test case should be valid"},
|
||||||
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
|
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
|
||||||
{"_somename", true, "RFC3696 test case should be valid"},
|
{"_somename", true, "RFC3696 test case should be valid"},
|
||||||
|
{"@host:mailbox", true, "Forward-path routes are valid"},
|
||||||
|
{"@a.com,@b.com:mailbox", true, "Multi-hop forward-path routes are valid"},
|
||||||
|
{"@a.com,mailbox", false, "Unterminated forward-path routes are invalid"},
|
||||||
}
|
}
|
||||||
for _, tt := range testTable {
|
for _, tt := range testTable {
|
||||||
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
|
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
|
||||||
@@ -372,3 +495,33 @@ func TestValidateLocal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecipientAddress verifies the Recipient.Address values returned by Addressing.NewRecipient.
|
||||||
|
// This function parses a RCPT TO path, not a To header. See rfc5321#section-4.1.2
|
||||||
|
func TestRecipientAddress(t *testing.T) {
|
||||||
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
|
|
||||||
|
tests := map[string]string{
|
||||||
|
"common": "user@example.com",
|
||||||
|
"with label": "user+mailbox@example.com",
|
||||||
|
"special chars": "a!#$%&'*+-/=?^_`{|}~@example.com",
|
||||||
|
"ipv4": "user@[127.0.0.1]",
|
||||||
|
"ipv6": "user@[IPv6:::1]",
|
||||||
|
"route host": "@host:user@example.com",
|
||||||
|
"route domain": "@route.com:user@example.com",
|
||||||
|
"multi-hop route": "@first.com,@second.com:user@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
r, err := localPolicy.NewRecipient(tc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse of %q failed: %v", tc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := r.Address.Address, tc; got != want {
|
||||||
|
t.Errorf("Got Address: %q, want: %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,14 +65,15 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||||
for i, part := range attachParts {
|
for i, part := range attachParts {
|
||||||
content := part.Content
|
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{
|
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||||
ContentType: part.ContentType,
|
ContentType: part.ContentType,
|
||||||
FileName: part.FileName,
|
FileName: part.FileName,
|
||||||
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
|
DownloadLink: link,
|
||||||
strconv.Itoa(i) + "/" + part.FileName,
|
ViewLink: link,
|
||||||
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
|
|
||||||
strconv.Itoa(i) + "/" + part.FileName,
|
|
||||||
MD5: hex.EncodeToString(checksum[:]),
|
MD5: hex.EncodeToString(checksum[:]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
@@ -67,7 +68,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
// Test JSON message headers
|
// Test JSON message headers
|
||||||
tzPDT := time.FixedZone("PDT", -7*3600)
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
tzPST := time.FixedZone("PST", -8*3600)
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
meta1 := message.Metadata{
|
meta1 := event.MessageMetadata{
|
||||||
Mailbox: "good",
|
Mailbox: "good",
|
||||||
ID: "0001",
|
ID: "0001",
|
||||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
@@ -75,7 +76,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
Subject: "subject 1",
|
Subject: "subject 1",
|
||||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
}
|
}
|
||||||
meta2 := message.Metadata{
|
meta2 := event.MessageMetadata{
|
||||||
Mailbox: "good",
|
Mailbox: "good",
|
||||||
ID: "0002",
|
ID: "0002",
|
||||||
From: &mail.Address{Name: "", Address: "from2@host"},
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
@@ -83,8 +84,8 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
Subject: "subject 2",
|
Subject: "subject 2",
|
||||||
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
}
|
}
|
||||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
|
||||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
|
||||||
|
|
||||||
// Check return code
|
// Check return code
|
||||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||||
@@ -178,7 +179,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
// Test JSON message headers
|
// Test JSON message headers
|
||||||
tzPST := time.FixedZone("PST", -8*3600)
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
msg1 := message.New(
|
msg1 := message.New(
|
||||||
message.Metadata{
|
event.MessageMetadata{
|
||||||
Mailbox: "good",
|
Mailbox: "good",
|
||||||
ID: "0001",
|
ID: "0001",
|
||||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
@@ -196,6 +197,10 @@ func TestRestMessage(t *testing.T) {
|
|||||||
"From": []string{"noreply@inbucket.org"},
|
"From": []string{"noreply@inbucket.org"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Attachments: []*enmime.Part{{
|
||||||
|
FileName: "favicon.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mm.AddMessage("good", msg1)
|
mm.AddMessage("good", msg1)
|
||||||
@@ -231,6 +236,10 @@ func TestRestMessage(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||||
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||||
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
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() {
|
if t.Failed() {
|
||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
@@ -246,7 +255,7 @@ func TestRestMarkSeen(t *testing.T) {
|
|||||||
// Create some messages.
|
// Create some messages.
|
||||||
tzPDT := time.FixedZone("PDT", -7*3600)
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
tzPST := time.FixedZone("PST", -8*3600)
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
meta1 := message.Metadata{
|
meta1 := event.MessageMetadata{
|
||||||
Mailbox: "good",
|
Mailbox: "good",
|
||||||
ID: "0001",
|
ID: "0001",
|
||||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
@@ -254,7 +263,7 @@ func TestRestMarkSeen(t *testing.T) {
|
|||||||
Subject: "subject 1",
|
Subject: "subject 1",
|
||||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
}
|
}
|
||||||
meta2 := message.Metadata{
|
meta2 := event.MessageMetadata{
|
||||||
Mailbox: "good",
|
Mailbox: "good",
|
||||||
ID: "0002",
|
ID: "0002",
|
||||||
From: &mail.Address{Name: "", Address: "from2@host"},
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
@@ -262,8 +271,8 @@ func TestRestMarkSeen(t *testing.T) {
|
|||||||
Subject: "subject 2",
|
Subject: "subject 2",
|
||||||
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
}
|
}
|
||||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
|
||||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
|
||||||
// Mark one read.
|
// Mark one read.
|
||||||
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
||||||
expectCode := 200
|
expectCode := 200
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
// JSONMessageHeaderV1 contains the basic header data for a message.
|
||||||
type JSONMessageHeaderV1 struct {
|
type JSONMessageHeaderV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
|
|||||||
Seen bool `json:"seen"`
|
Seen bool `json:"seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
|
||||||
type JSONMessageV1 struct {
|
type JSONMessageV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
|
|||||||
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageAttachmentV1 contains information about a MIME attachment
|
// JSONMessageAttachmentV1 contains information about a MIME attachment.
|
||||||
type JSONMessageAttachmentV1 struct {
|
type JSONMessageAttachmentV1 struct {
|
||||||
FileName string `json:"filename"`
|
FileName string `json:"filename"`
|
||||||
ContentType string `json:"content-type"`
|
ContentType string `json:"content-type"`
|
||||||
@@ -42,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
|
|||||||
MD5 string `json:"md5"`
|
MD5 string `json:"md5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
|
// JSONMessageBodyV1 contains the Text and HTML versions of the message body.
|
||||||
type JSONMessageBodyV1 struct {
|
type JSONMessageBodyV1 struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
HTML string `json:"html"`
|
HTML string `json:"html"`
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// JSONMessageIDV2 uniquely identifies a message.
|
||||||
|
type JSONMessageIDV2 struct {
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONMonitorEventV2 contains events for the Inbucket mailbox and monitor tabs.
|
||||||
|
type JSONMonitorEventV2 struct {
|
||||||
|
// Event variant: `message-deleted`, `message-stored`.
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Identifier *JSONMessageIDV2 `json:"identifier"`
|
||||||
|
Header *JSONMessageHeaderV1 `json:"header"`
|
||||||
|
}
|
||||||
@@ -22,4 +22,10 @@ func SetupRoutes(r *mux.Router) {
|
|||||||
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||||
r.Path("/v1/monitor/messages/{name}").Handler(
|
r.Path("/v1/monitor/messages/{name}").Handler(
|
||||||
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||||
|
|
||||||
|
// API v2
|
||||||
|
r.Path("/v2/monitor/messages").Handler(
|
||||||
|
web.Handler(MonitorAllMessagesV2)).Name("MonitorAllMessagesV2").Methods("GET")
|
||||||
|
r.Path("/v2/monitor/messages/{name}").Handler(
|
||||||
|
web.Handler(MonitorMailboxMessagesV2)).Name("MonitorMailboxMessagesV2").Methods("GET")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,71 +5,79 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
"github.com/inbucket/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Time allowed to write a message to the peer.
|
// Time allowed to write a message to the peer.
|
||||||
writeWait = 10 * time.Second
|
writeWaitV1 = 10 * time.Second
|
||||||
|
|
||||||
// Send pings to peer with this period. Must be less than pongWait.
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
pingPeriod = (pongWait * 9) / 10
|
pingPeriodV1 = (pongWaitV1 * 9) / 10
|
||||||
|
|
||||||
// Time allowed to read the next pong message from the peer.
|
// Time allowed to read the next pong message from the peer.
|
||||||
pongWait = 60 * time.Second
|
pongWaitV1 = 60 * time.Second
|
||||||
|
|
||||||
// Maximum message size allowed from peer.
|
// Maximum message size allowed from peer.
|
||||||
maxMessageSize = 512
|
maxMessageSizeV1 = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
// options for gorilla connection upgrader
|
// options for gorilla connection upgrader
|
||||||
var upgrader = websocket.Upgrader{
|
var upgraderV1 = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
// msgListener handles messages from the msghub
|
// msgListenerV1 handles messages from the msghub
|
||||||
type msgListener struct {
|
type msgListenerV1 struct {
|
||||||
hub *msghub.Hub // Global message hub
|
hub *msghub.Hub // Global message hub
|
||||||
c chan msghub.Message // Queue of messages from Receive()
|
c chan event.MessageMetadata // Queue of messages from Receive()
|
||||||
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
|
// newMsgListenerV1 creates a listener and registers it. Optional mailbox parameter will restrict
|
||||||
// messages sent to WebSocket to that mailbox only.
|
// messages sent to WebSocket to that mailbox only.
|
||||||
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListenerV1 {
|
||||||
ml := &msgListener{
|
ml := &msgListenerV1{
|
||||||
hub: hub,
|
hub: hub,
|
||||||
c: make(chan msghub.Message, 100),
|
c: make(chan event.MessageMetadata, 100),
|
||||||
mailbox: mailbox,
|
mailbox: mailbox,
|
||||||
}
|
}
|
||||||
hub.AddListener(ml)
|
hub.AddListener(ml)
|
||||||
return ml
|
return ml
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive handles an incoming message
|
// Receive handles an incoming message.
|
||||||
func (ml *msgListener) Receive(msg msghub.Message) error {
|
func (ml *msgListenerV1) Receive(msg event.MessageMetadata) error {
|
||||||
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||||
// Did not match mailbox name
|
// Did not match the watched mailbox name.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ml.c <- msg
|
ml.c <- msg
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete handles a deleted message.
|
||||||
|
func (ml *msgListenerV1) Delete(mailbox string, id string) error {
|
||||||
|
// Deletes are ignored in socketv1 API.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
||||||
func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
func (ml *msgListenerV1) WSReader(conn *websocket.Conn) {
|
||||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||||
defer ml.Close()
|
defer ml.Close()
|
||||||
conn.SetReadLimit(maxMessageSize)
|
conn.SetReadLimit(maxMessageSizeV1)
|
||||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
conn.SetReadDeadline(time.Now().Add(pongWaitV1))
|
||||||
conn.SetPongHandler(func(string) error {
|
conn.SetPongHandler(func(string) error {
|
||||||
slog.Debug().Msg("Got pong")
|
slog.Debug().Msg("Got pong")
|
||||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
conn.SetReadDeadline(time.Now().Add(pongWaitV1))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,8 +100,8 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WSWriter makes sure the websocket client is still connected
|
// WSWriter makes sure the websocket client is still connected
|
||||||
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
func (ml *msgListenerV1) WSWriter(conn *websocket.Conn) {
|
||||||
ticker := time.NewTicker(pingPeriod)
|
ticker := time.NewTicker(pingPeriodV1)
|
||||||
defer func() {
|
defer func() {
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
ml.Close()
|
ml.Close()
|
||||||
@@ -103,29 +111,19 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg, ok := <-ml.c:
|
case msg, ok := <-ml.c:
|
||||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
|
||||||
if !ok {
|
if !ok {
|
||||||
// msgListener closed, exit
|
// msgListener closed, exit
|
||||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
header := &model.JSONMessageHeaderV1{
|
if conn.WriteJSON(metadataToHeader(&msg)) != nil {
|
||||||
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
|
// Write failed
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Send ping
|
// Send ping
|
||||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
|
||||||
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||||
// Write error
|
// Write error
|
||||||
return
|
return
|
||||||
@@ -137,7 +135,7 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close removes the listener registration
|
// Close removes the listener registration
|
||||||
func (ml *msgListener) Close() {
|
func (ml *msgListenerV1) Close() {
|
||||||
select {
|
select {
|
||||||
case <-ml.c:
|
case <-ml.c:
|
||||||
// Already closed
|
// Already closed
|
||||||
@@ -152,7 +150,7 @@ func (ml *msgListener) Close() {
|
|||||||
func MonitorAllMessagesV1(
|
func MonitorAllMessagesV1(
|
||||||
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Upgrade to Websocket.
|
// Upgrade to Websocket.
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
conn, err := upgraderV1.Upgrade(w, req, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -164,7 +162,7 @@ func MonitorAllMessagesV1(
|
|||||||
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
// Create, register listener; then interact with conn.
|
// Create, register listener; then interact with conn.
|
||||||
ml := newMsgListener(ctx.MsgHub, "")
|
ml := newMsgListenerV1(ctx.MsgHub, "")
|
||||||
go ml.WSWriter(conn)
|
go ml.WSWriter(conn)
|
||||||
ml.WSReader(conn)
|
ml.WSReader(conn)
|
||||||
return nil
|
return nil
|
||||||
@@ -179,7 +177,7 @@ func MonitorMailboxMessagesV1(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Upgrade to Websocket.
|
// Upgrade to Websocket.
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
conn, err := upgraderV1.Upgrade(w, req, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -191,8 +189,21 @@ func MonitorMailboxMessagesV1(
|
|||||||
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
// Create, register listener; then interact with conn.
|
// Create, register listener; then interact with conn.
|
||||||
ml := newMsgListener(ctx.MsgHub, name)
|
ml := newMsgListenerV1(ctx.MsgHub, name)
|
||||||
go ml.WSWriter(conn)
|
go ml.WSWriter(conn)
|
||||||
ml.WSReader(conn)
|
ml.WSReader(conn)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metadataToHeader(msg *event.MessageMetadata) *model.JSONMessageHeaderV1 {
|
||||||
|
return &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: msg.Mailbox,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer.
|
||||||
|
writeWaitV2 = 10 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
|
pingPeriodV2 = (pongWaitV2 * 9) / 10
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer.
|
||||||
|
pongWaitV2 = 60 * time.Second
|
||||||
|
|
||||||
|
// Maximum message size allowed from peer.
|
||||||
|
maxMessageSizeV2 = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
// options for gorilla connection upgrader
|
||||||
|
var upgraderV2 = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
// msgListenerV2 handles messages from the msghub
|
||||||
|
type msgListenerV2 struct {
|
||||||
|
hub *msghub.Hub // Global message hub.
|
||||||
|
c chan *model.JSONMonitorEventV2 // Queue of incoming events.
|
||||||
|
mailbox string // Name of mailbox to monitor, "" == all mailboxes.
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMsgListenerV2 creates a listener and registers it. Optional mailbox parameter will restrict
|
||||||
|
// messages sent to WebSocket to that mailbox only.
|
||||||
|
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListenerV2 {
|
||||||
|
ml := &msgListenerV2{
|
||||||
|
hub: hub,
|
||||||
|
c: make(chan *model.JSONMonitorEventV2, 100),
|
||||||
|
mailbox: mailbox,
|
||||||
|
}
|
||||||
|
hub.AddListener(ml)
|
||||||
|
return ml
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive handles an incoming message.
|
||||||
|
func (ml *msgListenerV2) Receive(msg event.MessageMetadata) error {
|
||||||
|
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||||
|
// Did not match the watched mailbox name.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue for websocket.
|
||||||
|
ml.c <- &model.JSONMonitorEventV2{
|
||||||
|
Variant: "message-stored",
|
||||||
|
Header: metadataToHeader(&msg),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles a deleted message.
|
||||||
|
func (ml *msgListenerV2) Delete(mailbox string, id string) error {
|
||||||
|
if ml.mailbox != "" && ml.mailbox != mailbox {
|
||||||
|
// Did not match watched mailbox name.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue for websocket.
|
||||||
|
ml.c <- &model.JSONMonitorEventV2{
|
||||||
|
Variant: "message-deleted",
|
||||||
|
Identifier: &model.JSONMessageIDV2{
|
||||||
|
Mailbox: mailbox,
|
||||||
|
ID: id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
||||||
|
func (ml *msgListenerV2) WSReader(conn *websocket.Conn) {
|
||||||
|
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||||
|
defer ml.Close()
|
||||||
|
conn.SetReadLimit(maxMessageSizeV2)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWaitV2))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
slog.Debug().Msg("Got pong")
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWaitV2))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(
|
||||||
|
err,
|
||||||
|
websocket.CloseNormalClosure,
|
||||||
|
websocket.CloseGoingAway,
|
||||||
|
websocket.CloseNoStatusReceived,
|
||||||
|
) {
|
||||||
|
// Unexpected close code
|
||||||
|
slog.Warn().Err(err).Msg("Socket error")
|
||||||
|
} else {
|
||||||
|
slog.Debug().Msg("Closing socket")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSWriter makes sure the websocket client is still connected
|
||||||
|
func (ml *msgListenerV2) WSWriter(conn *websocket.Conn) {
|
||||||
|
ticker := time.NewTicker(pingPeriodV2)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
ml.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Handle messages from hub until msgListener is closed
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-ml.c:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWaitV2))
|
||||||
|
if !ok {
|
||||||
|
// msgListener closed, exit
|
||||||
|
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conn.WriteJSON(event) != nil {
|
||||||
|
// Write failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
// Send ping
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWaitV2))
|
||||||
|
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||||
|
// Write error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close removes the listener registration
|
||||||
|
func (ml *msgListenerV2) Close() {
|
||||||
|
select {
|
||||||
|
case <-ml.c:
|
||||||
|
// Already closed
|
||||||
|
default:
|
||||||
|
ml.hub.RemoveListener(ml)
|
||||||
|
close(ml.c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
|
||||||
|
// the client of all messages received.
|
||||||
|
func MonitorAllMessagesV2(
|
||||||
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Upgrade to Websocket.
|
||||||
|
conn, err := upgraderV2.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
|
ml := newMsgListenerV2(ctx.MsgHub, "")
|
||||||
|
go ml.WSWriter(conn)
|
||||||
|
ml.WSReader(conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorMailboxMessagesV2 is a web handler which upgrades the connection to a websocket and
|
||||||
|
// notifies the client of messages received by a particular mailbox.
|
||||||
|
func MonitorMailboxMessagesV2(
|
||||||
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Upgrade to Websocket.
|
||||||
|
conn, err := upgraderV2.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
|
ml := newMsgListenerV2(ctx.MsgHub, name)
|
||||||
|
go ml.WSWriter(conn)
|
||||||
|
ml.WSReader(conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -48,9 +48,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
UIDir: "../ui",
|
UIDir: "../ui",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
|
||||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
web.NewServer(cfg, mm, &msghub.Hub{})
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/luahost"
|
||||||
|
"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/stringutil"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Services holds the configured services.
|
||||||
|
type Services struct {
|
||||||
|
MsgHub *msghub.Hub
|
||||||
|
POP3Server *pop3.Server
|
||||||
|
RetentionScanner *storage.RetentionScanner
|
||||||
|
SMTPServer *smtp.Server
|
||||||
|
WebServer *web.Server
|
||||||
|
ExtHost *extension.Host
|
||||||
|
LuaHost *luahost.Host
|
||||||
|
notify chan error // Combined notification for failed services.
|
||||||
|
ready *sync.WaitGroup // Tracks services that have not reported ready.
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullAssembly wires up a complete Inbucket environment.
|
||||||
|
func FullAssembly(conf *config.Root) (*Services, error) {
|
||||||
|
// Configure extensions.
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.New(conf.Lua, extHost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure storage.
|
||||||
|
store, err := storage.FromConfig(conf.Storage, extHost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
|
// Configure shared components.
|
||||||
|
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
|
||||||
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
|
||||||
|
|
||||||
|
// Start Retention scanner.
|
||||||
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)
|
||||||
|
|
||||||
|
// Configure routes and build HTTP server.
|
||||||
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||||
|
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||||
|
webServer := web.NewServer(conf, mmanager, msgHub)
|
||||||
|
|
||||||
|
pop3Server := pop3.NewServer(conf.POP3, store)
|
||||||
|
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
|
||||||
|
|
||||||
|
return &Services{
|
||||||
|
MsgHub: msgHub,
|
||||||
|
RetentionScanner: retentionScanner,
|
||||||
|
POP3Server: pop3Server,
|
||||||
|
SMTPServer: smtpServer,
|
||||||
|
WebServer: webServer,
|
||||||
|
ExtHost: extHost,
|
||||||
|
LuaHost: luaHost,
|
||||||
|
ready: &sync.WaitGroup{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all services, returns immediately. Callers may use Notify to detect failed services.
|
||||||
|
func (s *Services) Start(ctx context.Context, readyFunc func()) {
|
||||||
|
go s.MsgHub.Start(ctx)
|
||||||
|
go s.WebServer.Start(ctx, s.makeReadyFunc())
|
||||||
|
go s.SMTPServer.Start(ctx, s.makeReadyFunc())
|
||||||
|
go s.POP3Server.Start(ctx, s.makeReadyFunc())
|
||||||
|
go s.RetentionScanner.Start(ctx)
|
||||||
|
|
||||||
|
// Notify when all services report ready.
|
||||||
|
go func() {
|
||||||
|
s.ready.Wait()
|
||||||
|
readyFunc()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify merges the error notification channels of all fallible services, allowing the process to
|
||||||
|
// be shutdown if needed.
|
||||||
|
func (s *Services) Notify() <-chan error {
|
||||||
|
c := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// TODO: What level to log failure.
|
||||||
|
select {
|
||||||
|
case err := <-s.POP3Server.Notify():
|
||||||
|
c <- err
|
||||||
|
case err := <-s.SMTPServer.Notify():
|
||||||
|
c <- err
|
||||||
|
case err := <-s.WebServer.Notify():
|
||||||
|
c <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Services) makeReadyFunc() func() {
|
||||||
|
s.ready.Add(1)
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(s.ready.Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// not an EOF
|
// not an EOF
|
||||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
if netErr, ok := err.(net.Error); ok {
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
|||||||
+21
-18
@@ -16,44 +16,50 @@ type Server struct {
|
|||||||
config config.POP3 // POP3 configuration.
|
config config.POP3 // POP3 configuration.
|
||||||
store storage.Store // Mail store.
|
store storage.Store // Mail store.
|
||||||
listener net.Listener // TCP listener.
|
listener net.Listener // TCP listener.
|
||||||
globalShutdown chan bool // Inbucket shutdown signal.
|
|
||||||
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||||
|
notify chan error // Notify on fatal error.
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Server struct.
|
// NewServer creates a new, unstarted, POP3 server.
|
||||||
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
|
func NewServer(pop3Config config.POP3, store storage.Store) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
config: pop3Config,
|
config: pop3Config,
|
||||||
store: store,
|
store: store,
|
||||||
globalShutdown: shutdownChan,
|
|
||||||
wg: new(sync.WaitGroup),
|
wg: new(sync.WaitGroup),
|
||||||
|
notify: make(chan error, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the server and listen for connections
|
// Start the server and listen for connections
|
||||||
func (s *Server) Start(ctx context.Context) {
|
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
||||||
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
|
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
|
||||||
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
|
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
|
||||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Listener go routine.
|
|
||||||
|
// Start listener go routine.
|
||||||
go s.serve(ctx)
|
go s.serve(ctx)
|
||||||
|
readyFunc()
|
||||||
|
|
||||||
// Wait for shutdown.
|
// Wait for shutdown.
|
||||||
select {
|
select {
|
||||||
case _ = <-ctx.Done():
|
case _ = <-ctx.Done():
|
||||||
}
|
}
|
||||||
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
|
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
|
||||||
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
|
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
|
||||||
|
|
||||||
// Closing the listener will cause the serve() go routine to exit.
|
// Closing the listener will cause the serve() go routine to exit.
|
||||||
if err := s.listener.Close(); err != nil {
|
if err := s.listener.Close(); err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to close POP3 listener")
|
slog.Error().Err(err).Msg("Failed to close POP3 listener")
|
||||||
@@ -88,7 +94,8 @@ func (s *Server) serve(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// Something went wrong.
|
// Something went wrong.
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,18 +107,14 @@ func (s *Server) serve(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) emergencyShutdown() {
|
|
||||||
// Shutdown Inbucket
|
|
||||||
select {
|
|
||||||
case _ = <-s.globalShutdown:
|
|
||||||
default:
|
|
||||||
close(s.globalShutdown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain causes the caller to block until all active POP3 sessions have finished
|
// Drain causes the caller to block until all active POP3 sessions have finished
|
||||||
func (s *Server) Drain() {
|
func (s *Server) Drain() {
|
||||||
// Wait for sessions to close
|
// Wait for sessions to close
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
|
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify allows the running POP3 server to be monitored for a fatal error.
|
||||||
|
func (s *Server) Notify() <-chan error {
|
||||||
|
return s.notify
|
||||||
|
}
|
||||||
|
|||||||
+130
-29
@@ -13,9 +13,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// State tracks the current mode of our SMTP state machine.
|
// State tracks the current mode of our SMTP state machine.
|
||||||
@@ -25,10 +25,27 @@ const (
|
|||||||
// timeStampFormat to use in Received header.
|
// timeStampFormat to use in Received header.
|
||||||
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
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: Waiting for HELO
|
||||||
GREET State = iota
|
GREET State = iota
|
||||||
// READY State: Got HELO, waiting for MAIL
|
// READY State: Got HELO, waiting for MAIL
|
||||||
READY
|
READY
|
||||||
|
// LOGIN State: Got AUTH LOGIN command, expecting Username
|
||||||
|
LOGIN
|
||||||
|
// PASSWORD State: Got Username, expecting password
|
||||||
|
PASSWORD
|
||||||
// MAIL State: Got MAIL, accepting RCPTs
|
// MAIL State: Got MAIL, accepting RCPTs
|
||||||
MAIL
|
MAIL
|
||||||
// DATA State: Got DATA, waiting for "."
|
// DATA State: Got DATA, waiting for "."
|
||||||
@@ -37,11 +54,11 @@ const (
|
|||||||
QUIT
|
QUIT
|
||||||
)
|
)
|
||||||
|
|
||||||
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
|
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
|
||||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
|
||||||
// (?:) is non-grouping sub-match
|
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
|
||||||
var fromRegex = regexp.MustCompile(
|
var fromRegex = regexp.MustCompile(
|
||||||
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( ([\w= ]|=<>)+)?$`)
|
||||||
|
|
||||||
func (s State) String() string {
|
func (s State) String() string {
|
||||||
switch s {
|
switch s {
|
||||||
@@ -76,6 +93,7 @@ var commands = map[string]bool{
|
|||||||
"QUIT": true,
|
"QUIT": true,
|
||||||
"TURN": true,
|
"TURN": true,
|
||||||
"STARTTLS": true,
|
"STARTTLS": true,
|
||||||
|
"AUTH": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session holds the state of an SMTP session
|
// Session holds the state of an SMTP session
|
||||||
@@ -126,8 +144,8 @@ func (s *Session) String() string {
|
|||||||
* 4. If bad cmd, respond error
|
* 4. If bad cmd, respond error
|
||||||
* 5. Goto 2
|
* 5. Goto 2
|
||||||
*/
|
*/
|
||||||
func (s *Server) startSession(id int, conn net.Conn) {
|
func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
|
||||||
logger := log.Hook(logHook{}).With().
|
logger = logger.Hook(logHook{}).With().
|
||||||
Str("module", "smtp").
|
Str("module", "smtp").
|
||||||
Str("remote", conn.RemoteAddr().String()).
|
Str("remote", conn.RemoteAddr().String()).
|
||||||
Int("session", id).Logger()
|
Int("session", id).Logger()
|
||||||
@@ -153,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
line, err := ssn.readLine()
|
line, err := ssn.readLine()
|
||||||
if err == nil {
|
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 {
|
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||||
// Check against valid SMTP commands
|
// Check against valid SMTP commands
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
@@ -219,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// not an EOF
|
// Not an EOF
|
||||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
if netErr, ok := err.(net.Error); ok {
|
if netErr, ok := err.(net.Error); ok {
|
||||||
if netErr.Timeout() {
|
if netErr.Timeout() {
|
||||||
@@ -257,9 +285,10 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.remoteDomain = domain
|
s.remoteDomain = domain
|
||||||
// features before SIZE per RFC
|
// Features before SIZE per RFC
|
||||||
s.send("250-" + readyBanner)
|
s.send("250-" + readyBanner)
|
||||||
s.send("250-8BITMIME")
|
s.send("250-8BITMIME")
|
||||||
|
s.send("250-AUTH PLAIN LOGIN")
|
||||||
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||||
s.send("250-STARTTLS")
|
s.send("250-STARTTLS")
|
||||||
}
|
}
|
||||||
@@ -281,30 +310,71 @@ func parseHelloArgument(arg string) (string, error) {
|
|||||||
return domain, nil
|
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
|
// READY state -> waiting for MAIL
|
||||||
|
// AUTH can change
|
||||||
func (s *Session) readyHandler(cmd string, arg string) {
|
func (s *Session) readyHandler(cmd string, arg string) {
|
||||||
if cmd == "STARTTLS" {
|
if cmd == "STARTTLS" {
|
||||||
if !s.Server.config.TLSEnabled {
|
if !s.Server.config.TLSEnabled {
|
||||||
// invalid command since unconfigured
|
// Invalid command since TLS unconfigured.
|
||||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||||
s.send("454 TLS unavailable on the server")
|
s.send("454 TLS unavailable on the server")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.tlsState != nil {
|
if s.tlsState != nil {
|
||||||
// tls state previously valid
|
// TLS state previously valid.
|
||||||
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||||
s.send("454 A TLS session already agreed upon.")
|
s.send("454 A TLS session already agreed upon.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Debug().Msg("Initiating TLS context.")
|
s.logger.Debug().Msg("Initiating TLS context.")
|
||||||
|
|
||||||
|
// Start TLS connection handshake.
|
||||||
s.send("220 STARTTLS")
|
s.send("220 STARTTLS")
|
||||||
// start tls connection handshake
|
|
||||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||||
s.conn = tlsConn
|
s.conn = tlsConn
|
||||||
s.text = textproto.NewConn(s.conn)
|
s.text = textproto.NewConn(s.conn)
|
||||||
s.tlsState = new(tls.ConnectionState)
|
s.tlsState = new(tls.ConnectionState)
|
||||||
*s.tlsState = tlsConn.ConnectionState()
|
*s.tlsState = tlsConn.ConnectionState()
|
||||||
s.enterState(GREET)
|
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" {
|
} else if cmd == "MAIL" {
|
||||||
// Capture group 1: from address. 2: optional params.
|
// Capture group 1: from address. 2: optional params.
|
||||||
m := fromRegex.FindStringSubmatch(arg)
|
m := fromRegex.FindStringSubmatch(arg)
|
||||||
@@ -314,11 +384,16 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
from := m[1]
|
from := m[1]
|
||||||
if _, _, err := policy.ParseEmailAddress(from); err != nil {
|
localpart, domain, err := policy.ParseEmailAddress(from)
|
||||||
|
if from != "" && err != nil {
|
||||||
s.send("501 Bad sender address syntax")
|
s.send("501 Bad sender address syntax")
|
||||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if from == "" {
|
||||||
|
from = "unspecified"
|
||||||
|
}
|
||||||
|
|
||||||
// This is where the client may put BODY=8BITMIME, but we already
|
// This is where the client may put BODY=8BITMIME, but we already
|
||||||
// read the DATA as bytes, so it does not effect our processing.
|
// read the DATA as bytes, so it does not effect our processing.
|
||||||
if m[2] != "" {
|
if m[2] != "" {
|
||||||
@@ -342,10 +417,27 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process through extensions.
|
||||||
|
extResult := s.extHost.Events.BeforeMailAccepted.Emit(
|
||||||
|
&event.AddressParts{Local: localpart, Domain: domain})
|
||||||
|
|
||||||
|
if extResult == nil || *extResult {
|
||||||
|
// Permitted by extension, or none had an opinion.
|
||||||
s.from = from
|
s.from = from
|
||||||
s.logger.Info().Msgf("Mail from: %v", from)
|
s.logger.Info().Msgf("Mail from: %v", from)
|
||||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||||
s.enterState(MAIL)
|
s.enterState(MAIL)
|
||||||
|
} else {
|
||||||
|
s.send("550 Mail denied by policy")
|
||||||
|
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if cmd == "EHLO" {
|
||||||
|
// Reset session
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||||
|
s.reset()
|
||||||
|
s.send("250 Session reset")
|
||||||
} else {
|
} else {
|
||||||
s.ooSeq(cmd)
|
s.ooSeq(cmd)
|
||||||
}
|
}
|
||||||
@@ -394,6 +486,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
|||||||
}
|
}
|
||||||
s.enterState(DATA)
|
s.enterState(DATA)
|
||||||
return
|
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)
|
s.ooSeq(cmd)
|
||||||
}
|
}
|
||||||
@@ -422,6 +520,7 @@ func (s *Session) dataHandler() {
|
|||||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
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,
|
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||||
tstamp)
|
tstamp)
|
||||||
|
|
||||||
// Deliver message.
|
// Deliver message.
|
||||||
_, err := s.manager.Deliver(
|
_, err := s.manager.Deliver(
|
||||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||||
@@ -502,38 +601,40 @@ func (s *Session) readLine() (line string, err error) {
|
|||||||
|
|
||||||
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||||
line = strings.TrimRight(line, "\r\n")
|
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 {
|
switch {
|
||||||
case l == 0:
|
case l == 0:
|
||||||
return "", "", true
|
return "", "", true
|
||||||
case l < 4:
|
case l < 4:
|
||||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
case l == 4 || l == 8:
|
}
|
||||||
|
|
||||||
|
if hasArg {
|
||||||
|
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
|
||||||
|
}
|
||||||
|
|
||||||
return strings.ToUpper(line), "", true
|
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
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseArgs takes the arguments proceeding a command and files them
|
// parseArgs takes the arguments proceeding a command and files them
|
||||||
// into a map[string]string after uppercasing each key. Sample arg
|
// into a map[string]string after uppercasing each key. Sample arg
|
||||||
// string:
|
// string:
|
||||||
|
//
|
||||||
// " BODY=8BITMIME SIZE=1024"
|
// " BODY=8BITMIME SIZE=1024"
|
||||||
|
//
|
||||||
// The leading space is mandatory.
|
// The leading space is mandatory.
|
||||||
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||||
args = make(map[string]string)
|
args = make(map[string]string)
|
||||||
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
|
||||||
pm := re.FindAllStringSubmatch(arg, -1)
|
pm := re.FindAllStringSubmatch(arg, -1)
|
||||||
if pm == nil {
|
if pm == nil {
|
||||||
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
||||||
|
|||||||
+292
-109
@@ -1,22 +1,23 @@
|
|||||||
package smtp
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
@@ -24,14 +25,44 @@ type scriptStep struct {
|
|||||||
expect int
|
expect int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test commands in GREET state
|
// Test valid commands in GREET state.
|
||||||
|
func TestGreetStateValidCommands(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
|
||||||
|
tests := []scriptStep{
|
||||||
|
{"HELO mydomain", 250},
|
||||||
|
{"HELO mydom.com", 250},
|
||||||
|
{"HelO mydom.com", 250},
|
||||||
|
{"helo 127.0.0.1", 250},
|
||||||
|
{"HELO ABC", 250},
|
||||||
|
{"EHLO mydomain", 250},
|
||||||
|
{"EHLO mydom.com", 250},
|
||||||
|
{"EhlO mydom.com", 250},
|
||||||
|
{"ehlo 127.0.0.1", 250},
|
||||||
|
{"EHLO a", 250},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.send, func(t *testing.T) {
|
||||||
|
defer server.Drain() // Required to prevent test logging data race.
|
||||||
|
script := []scriptStep{
|
||||||
|
tc,
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid commands in GREET state.
|
||||||
func TestGreetState(t *testing.T) {
|
func TestGreetState(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
server, logbuf, teardown := setupSMTPServer(ds)
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
defer teardown()
|
defer server.Drain() // Required to prevent test logging data race.
|
||||||
|
|
||||||
// Test out some mangled HELOs
|
tests := []scriptStep{
|
||||||
script := []scriptStep{
|
|
||||||
{"HELO", 501},
|
{"HELO", 501},
|
||||||
{"EHLO", 501},
|
{"EHLO", 501},
|
||||||
{"HELLO", 500},
|
{"HELLO", 500},
|
||||||
@@ -39,55 +70,140 @@ func TestGreetState(t *testing.T) {
|
|||||||
{"hello", 500},
|
{"hello", 500},
|
||||||
{"Outlook", 500},
|
{"Outlook", 500},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.send, func(t *testing.T) {
|
||||||
|
defer server.Drain() // Required to prevent test logging data race.
|
||||||
|
script := []scriptStep{
|
||||||
|
tc,
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyEnvelope(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
defer server.Drain()
|
||||||
|
|
||||||
|
// Test out some empty envelope without blanks
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<>", 250},
|
||||||
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid HELOs
|
// Test out some empty envelope with blanks
|
||||||
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
|
script = []scriptStep{
|
||||||
t.Error(err)
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM: <>", 250},
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid EHLOs
|
|
||||||
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 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
|
// Test AUTH commands.
|
||||||
func TestReadyState(t *testing.T) {
|
func TestAuth(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
server, logbuf, teardown := setupSMTPServer(ds)
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
defer teardown()
|
defer server.Drain()
|
||||||
|
|
||||||
// Test out some mangled READY commands
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test TLS commands.
|
||||||
|
func TestTLS(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
defer server.Drain()
|
||||||
|
|
||||||
|
// Test Start TLS parsing.
|
||||||
script := []scriptStep{
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
|
{"STARTTLS", 454}, // TLS unconfigured.
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test valid commands in READY state.
|
||||||
|
func TestReadyStateValidCommands(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
|
||||||
|
// Test out some valid MAIL commands
|
||||||
|
tests := []scriptStep{
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"MAIL FROM: <john@gmail.com>", 250},
|
||||||
|
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
|
||||||
|
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
|
||||||
|
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
|
||||||
|
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
|
||||||
|
{"MAIL FROM:<\"first last\"@space.com>", 250},
|
||||||
|
{"MAIL FROM:<user\\@internal@external.com>", 250},
|
||||||
|
{"MAIL FROM:<user\\>name@host.com>", 250},
|
||||||
|
{"MAIL FROM:<\"user>name\"@host.com>", 250},
|
||||||
|
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.send, func(t *testing.T) {
|
||||||
|
defer server.Drain()
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
tc,
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid commands in READY state.
|
||||||
|
func TestReadyStateInvalidCommands(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
|
||||||
|
tests := []scriptStep{
|
||||||
{"FOOB", 500},
|
{"FOOB", 500},
|
||||||
{"HELO", 503},
|
{"HELO", 503},
|
||||||
{"DATA", 503},
|
{"DATA", 503},
|
||||||
@@ -99,50 +215,27 @@ func TestReadyState(t *testing.T) {
|
|||||||
{"MAIL FROM:<first@last@gmail.com>", 501},
|
{"MAIL FROM:<first@last@gmail.com>", 501},
|
||||||
{"MAIL FROM:<first last@gmail.com>", 501},
|
{"MAIL FROM:<first last@gmail.com>", 501},
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test out some valid MAIL commands
|
for _, tc := range tests {
|
||||||
script = []scriptStep{
|
t.Run(tc.send, func(t *testing.T) {
|
||||||
|
defer server.Drain()
|
||||||
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM:<john@gmail.com>", 250},
|
tc,
|
||||||
{"RSET", 250},
|
{"QUIT", 221}}
|
||||||
{"MAIL FROM: <john@gmail.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<\"first last\"@space.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<user\\@internal@external.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<user\\>name@host.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<\"user>name\"@host.com>", 250},
|
|
||||||
{"RSET", 250},
|
|
||||||
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
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 MAIL state
|
// Test commands in MAIL state
|
||||||
func TestMailState(t *testing.T) {
|
func TestMailState(t *testing.T) {
|
||||||
mds := test.NewStore()
|
mds := test.NewStore()
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
server := setupSMTPServer(mds, extension.NewHost())
|
||||||
defer teardown()
|
defer server.Drain()
|
||||||
|
|
||||||
// Test out some mangled READY commands
|
// Test out some mangled READY commands
|
||||||
script := []scriptStep{
|
script := []scriptStep{
|
||||||
@@ -174,6 +267,8 @@ func TestMailState(t *testing.T) {
|
|||||||
{"RSET", 250},
|
{"RSET", 250},
|
||||||
{"MAIL FROM:<john@gmail.com>", 250},
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
{`RCPT TO:<"first/last"@host.com`, 250},
|
{`RCPT TO:<"first/last"@host.com`, 250},
|
||||||
|
{"RCPT TO:<u1@[127.0.0.1]>", 250},
|
||||||
|
{"RCPT TO:<u1@[IPv6:2001:db8:aaaa:1::100]>", 250},
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -206,6 +301,19 @@ func TestMailState(t *testing.T) {
|
|||||||
t.Error(err)
|
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
|
// Test RSET
|
||||||
script = []scriptStep{
|
script = []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
@@ -228,23 +336,16 @@ func TestMailState(t *testing.T) {
|
|||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
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 DATA state
|
// Test commands in DATA state
|
||||||
func TestDataState(t *testing.T) {
|
func TestDataState(t *testing.T) {
|
||||||
mds := test.NewStore()
|
mds := test.NewStore()
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
server := setupSMTPServer(mds, extension.NewHost())
|
||||||
defer teardown()
|
defer server.Drain()
|
||||||
|
|
||||||
var script []scriptStep
|
var script []scriptStep
|
||||||
pipe := setupSMTPSession(server)
|
pipe := setupSMTPSession(t, server)
|
||||||
c := textproto.NewConn(pipe)
|
c := textproto.NewConn(pipe)
|
||||||
|
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
@@ -259,6 +360,7 @@ func TestDataState(t *testing.T) {
|
|||||||
if err := playScriptAgainst(t, c, script); err != nil {
|
if err := playScriptAgainst(t, c, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
body := `To: u1@gmail.com
|
body := `To: u1@gmail.com
|
||||||
From: john@gmail.com
|
From: john@gmail.com
|
||||||
@@ -272,9 +374,11 @@ Hi!
|
|||||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
|
_, _ = c.Cmd("QUIT")
|
||||||
|
_, _, _ = c.ReadCodeLine(221)
|
||||||
|
|
||||||
// Test with no useful headers.
|
// Test with no useful headers.
|
||||||
pipe = setupSMTPSession(server)
|
pipe = setupSMTPSession(t, server)
|
||||||
c = textproto.NewConn(pipe)
|
c = textproto.NewConn(pipe)
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
t.Errorf("Expected a 220 greeting, got %v", code)
|
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||||
@@ -288,6 +392,7 @@ Hi!
|
|||||||
if err := playScriptAgainst(t, c, script); err != nil {
|
if err := playScriptAgainst(t, c, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
body = `X-Useless-Header: true
|
body = `X-Useless-Header: true
|
||||||
|
|
||||||
@@ -299,18 +404,13 @@ Hi! Can you still deliver this?
|
|||||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
|
_, _ = c.Cmd("QUIT")
|
||||||
if t.Failed() {
|
_, _, _ = c.ReadCodeLine(221)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// playSession creates a new session, reads the greeting and then plays the script
|
// playSession creates a new session, reads the greeting and then plays the script
|
||||||
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
||||||
pipe := setupSMTPSession(server)
|
pipe := setupSMTPSession(t, server)
|
||||||
c := textproto.NewConn(pipe)
|
c := textproto.NewConn(pipe)
|
||||||
|
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
@@ -351,6 +451,93 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tests "MAIL FROM" emits BeforeMailAccepted event.
|
||||||
|
func TestBeforeMailAcceptedEventEmitted(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
server := setupSMTPServer(ds, extHost)
|
||||||
|
defer server.Drain()
|
||||||
|
|
||||||
|
var got *event.AddressParts
|
||||||
|
extHost.Events.BeforeMailAccepted.AddListener(
|
||||||
|
"test",
|
||||||
|
func(addr event.AddressParts) *bool {
|
||||||
|
got = &addr
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Play and verify SMTP session.
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, got, "BeforeMailListener did not receive Address")
|
||||||
|
assert.Equal(t, "john", got.Local, "Address local part had wrong value")
|
||||||
|
assert.Equal(t, "gmail.com", got.Domain, "Address domain part had wrong value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test "MAIL FROM" acts on BeforeMailAccepted event result.
|
||||||
|
func TestBeforeMailAcceptedEventResponse(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
server := setupSMTPServer(ds, extHost)
|
||||||
|
defer server.Drain()
|
||||||
|
|
||||||
|
var shouldReturn *bool
|
||||||
|
var gotEvent *event.AddressParts
|
||||||
|
extHost.Events.BeforeMailAccepted.AddListener(
|
||||||
|
"test",
|
||||||
|
func(addr event.AddressParts) *bool {
|
||||||
|
gotEvent = &addr
|
||||||
|
return shouldReturn
|
||||||
|
})
|
||||||
|
|
||||||
|
allowRes := true
|
||||||
|
denyRes := false
|
||||||
|
tcs := map[string]struct {
|
||||||
|
script scriptStep // Command to send and SMTP code expected.
|
||||||
|
eventRes *bool // Response to send from event listener.
|
||||||
|
}{
|
||||||
|
"allow": {
|
||||||
|
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
eventRes: &allowRes,
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
script: scriptStep{"MAIL FROM:<john@gmail.com>", 550},
|
||||||
|
eventRes: &denyRes,
|
||||||
|
},
|
||||||
|
"defer": {
|
||||||
|
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
eventRes: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tcs {
|
||||||
|
tc := tc
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
// Reset event listener.
|
||||||
|
shouldReturn = tc.eventRes
|
||||||
|
gotEvent = nil
|
||||||
|
|
||||||
|
// Play and verify SMTP session.
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
tc.script,
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, gotEvent, "BeforeMailListener did not receive Address")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// net.Pipe does not implement deadlines
|
// net.Pipe does not implement deadlines
|
||||||
type mockConn struct {
|
type mockConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
@@ -360,7 +547,7 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
|||||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
|
func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
|
||||||
cfg := &config.Root{
|
cfg := &config.Root{
|
||||||
MailboxNaming: config.FullNaming,
|
MailboxNaming: config.FullNaming,
|
||||||
SMTP: config.SMTP{
|
SMTP: config.SMTP{
|
||||||
@@ -373,28 +560,24 @@ func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown f
|
|||||||
Timeout: 5,
|
Timeout: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Capture log output.
|
|
||||||
buf = new(bytes.Buffer)
|
|
||||||
log.SetOutput(buf)
|
|
||||||
// Create a server, don't start it.
|
// Create a server, don't start it.
|
||||||
shutdownChan := make(chan bool)
|
|
||||||
teardown = func() {
|
|
||||||
close(shutdownChan)
|
|
||||||
}
|
|
||||||
addrPolicy := &policy.Addressing{Config: cfg}
|
addrPolicy := &policy.Addressing{Config: cfg}
|
||||||
manager := &message.StoreManager{Store: ds}
|
manager := &message.StoreManager{Store: ds}
|
||||||
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
|
|
||||||
return s, buf, teardown
|
return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionNum int
|
var sessionNum int
|
||||||
|
|
||||||
func setupSMTPSession(server *Server) net.Conn {
|
func setupSMTPSession(t *testing.T, server *Server) net.Conn {
|
||||||
// Pair of pipes to communicate.
|
logger := zerolog.New(zerolog.NewTestWriter(t))
|
||||||
serverConn, clientConn := net.Pipe()
|
serverConn, clientConn := net.Pipe()
|
||||||
|
|
||||||
// Start the session.
|
// Start the session.
|
||||||
server.wg.Add(1)
|
server.wg.Add(1)
|
||||||
sessionNum++
|
sessionNum++
|
||||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
go server.startSession(sessionNum, &mockConn{serverConn}, logger)
|
||||||
|
|
||||||
return clientConn
|
return clientConn
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-22
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/metric"
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
@@ -59,20 +60,21 @@ func init() {
|
|||||||
// Server holds the configuration and state of our SMTP server.
|
// Server holds the configuration and state of our SMTP server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config config.SMTP // SMTP configuration.
|
config config.SMTP // SMTP configuration.
|
||||||
|
tlsConfig *tls.Config // TLS encryption configuration.
|
||||||
addrPolicy *policy.Addressing // Address policy.
|
addrPolicy *policy.Addressing // Address policy.
|
||||||
globalShutdown chan bool // Shuts down Inbucket.
|
|
||||||
manager message.Manager // Used to deliver messages.
|
manager message.Manager // Used to deliver messages.
|
||||||
|
extHost *extension.Host // Extension event processor.
|
||||||
listener net.Listener // Incoming network connections.
|
listener net.Listener // Incoming network connections.
|
||||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||||
tlsConfig *tls.Config
|
notify chan error // Notify on fatal error.
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Server instance with the specificed config.
|
// NewServer creates a new, unstarted, SMTP server instance with the specificed config.
|
||||||
func NewServer(
|
func NewServer(
|
||||||
smtpConfig config.SMTP,
|
smtpConfig config.SMTP,
|
||||||
globalShutdown chan bool,
|
|
||||||
manager message.Manager,
|
manager message.Manager,
|
||||||
apolicy *policy.Addressing,
|
apolicy *policy.Addressing,
|
||||||
|
extHost *extension.Host,
|
||||||
) *Server {
|
) *Server {
|
||||||
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||||
tlsConfig := &tls.Config{}
|
tlsConfig := &tls.Config{}
|
||||||
@@ -91,36 +93,43 @@ func NewServer(
|
|||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
config: smtpConfig,
|
config: smtpConfig,
|
||||||
globalShutdown: globalShutdown,
|
tlsConfig: tlsConfig,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
addrPolicy: apolicy,
|
addrPolicy: apolicy,
|
||||||
|
extHost: extHost,
|
||||||
wg: new(sync.WaitGroup),
|
wg: new(sync.WaitGroup),
|
||||||
tlsConfig: tlsConfig,
|
notify: make(chan error, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the listener and handle incoming connections.
|
// Start the listener and handle incoming connections.
|
||||||
func (s *Server) Start(ctx context.Context) {
|
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
||||||
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
|
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
|
||||||
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
|
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
|
||||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Listener go routine.
|
|
||||||
|
// Start listener go routine.
|
||||||
go s.serve(ctx)
|
go s.serve(ctx)
|
||||||
|
readyFunc()
|
||||||
|
|
||||||
// Wait for shutdown.
|
// Wait for shutdown.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
|
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
|
||||||
slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
|
slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
|
||||||
|
|
||||||
// Closing the listener will cause the serve() go routine to exit.
|
// Closing the listener will cause the serve() go routine to exit.
|
||||||
if err := s.listener.Close(); err != nil {
|
if err := s.listener.Close(); err != nil {
|
||||||
slog.Error().Err(err).Msg("Failed to close SMTP listener")
|
slog.Error().Err(err).Msg("Failed to close SMTP listener")
|
||||||
@@ -156,7 +165,8 @@ func (s *Server) serve(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// Something went wrong.
|
// Something went wrong.
|
||||||
s.emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,23 +174,18 @@ func (s *Server) serve(ctx context.Context) {
|
|||||||
tempDelay = 0
|
tempDelay = 0
|
||||||
expConnectsTotal.Add(1)
|
expConnectsTotal.Add(1)
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
go s.startSession(sessionID, conn)
|
go s.startSession(sessionID, conn, log.Logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) emergencyShutdown() {
|
|
||||||
// Shutdown Inbucket.
|
|
||||||
select {
|
|
||||||
case <-s.globalShutdown:
|
|
||||||
default:
|
|
||||||
close(s.globalShutdown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain causes the caller to block until all active SMTP sessions have finished
|
// Drain causes the caller to block until all active SMTP sessions have finished
|
||||||
func (s *Server) Drain() {
|
func (s *Server) Drain() {
|
||||||
// Wait for sessions to close.
|
// Wait for sessions to close.
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained")
|
}
|
||||||
|
|
||||||
|
// Notify allows the running SMTP server to be monitored for a fatal error.
|
||||||
|
func (s *Server) Notify() <-chan error {
|
||||||
|
return s.notify
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
type jsonAppConfig struct {
|
type jsonAppConfig struct {
|
||||||
|
BasePath string `json:"base-path"`
|
||||||
MonitorVisible bool `json:"monitor-visible"`
|
MonitorVisible bool `json:"monitor-visible"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,3 +84,23 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, req)
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+72
-34
@@ -5,10 +5,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"expvar"
|
"expvar"
|
||||||
|
"html/template"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +34,6 @@ var (
|
|||||||
rootConfig *config.Root
|
rootConfig *config.Root
|
||||||
server *http.Server
|
server *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
globalShutdown chan bool
|
|
||||||
|
|
||||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||||
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
||||||
@@ -42,57 +44,93 @@ func init() {
|
|||||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sets up things for unit tests or the Start() method.
|
// Server defines an instance of the Web server.
|
||||||
func Initialize(
|
type Server struct {
|
||||||
|
// TODO Migrate global vars here.
|
||||||
|
notify chan error // Notify on fatal error.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer sets up things for unit tests or the Start() method.
|
||||||
|
func NewServer(
|
||||||
conf *config.Root,
|
conf *config.Root,
|
||||||
shutdownChan chan bool,
|
|
||||||
mm message.Manager,
|
mm message.Manager,
|
||||||
mh *msghub.Hub) {
|
mh *msghub.Hub) *Server {
|
||||||
|
|
||||||
rootConfig = conf
|
rootConfig = conf
|
||||||
globalShutdown = shutdownChan
|
|
||||||
|
|
||||||
// NewContext() will use this DataStore for the web handlers.
|
// NewContext() will use this DataStore for the web handlers.
|
||||||
msgHub = mh
|
msgHub = mh
|
||||||
manager = mm
|
manager = mm
|
||||||
|
|
||||||
|
// Redirect requests to / if there is a base path configured.
|
||||||
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
redirectBase := prefix("/")
|
||||||
|
if redirectBase != "/" {
|
||||||
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||||
|
Msg("Base path configured")
|
||||||
|
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamic paths.
|
// Dynamic paths.
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||||
Msg("Web UI content mapped")
|
Msg("Web UI content mapped")
|
||||||
Router.Handle("/debug/vars", expvar.Handler())
|
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||||
if conf.Web.PProf {
|
if conf.Web.PProf {
|
||||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||||
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||||
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||||
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||||
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
|
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||||
log.Warn().Str("module", "web").Str("phase", "startup").
|
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||||
Msg("Go pprof tools installed to /debug/pprof")
|
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static paths.
|
// Static paths.
|
||||||
Router.PathPrefix("/static").Handler(
|
Router.PathPrefix(prefix("/static")).Handler(
|
||||||
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
|
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||||
Router.Path("/favicon.png").Handler(
|
Router.Path(prefix("/favicon.png")).Handler(
|
||||||
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
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.
|
// SPA managed paths.
|
||||||
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||||
fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
|
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||||
Router.Path("/").Handler(spaHandler)
|
Router.Path(prefix("/")).Handler(spaHandler)
|
||||||
Router.Path("/monitor").Handler(spaHandler)
|
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||||
Router.Path("/status").Handler(spaHandler)
|
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||||
Router.PathPrefix("/m/").Handler(spaHandler)
|
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||||
|
|
||||||
// Error handlers.
|
// Error handlers.
|
||||||
Router.NotFoundHandler = noMatchHandler(
|
Router.NotFoundHandler = noMatchHandler(
|
||||||
http.StatusNotFound, "No route matches URI path")
|
http.StatusNotFound, "No route matches URI path")
|
||||||
Router.MethodNotAllowedHandler = noMatchHandler(
|
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||||
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
notify: make(chan error, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins listening for HTTP requests
|
// Start begins listening for HTTP requests
|
||||||
func Start(ctx context.Context) {
|
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: rootConfig.Web.Addr,
|
Addr: rootConfig.Web.Addr,
|
||||||
Handler: requestLoggingWrapper(Router),
|
Handler: requestLoggingWrapper(Router),
|
||||||
@@ -108,12 +146,14 @@ func Start(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
Msg("HTTP failed to start TCP4 listener")
|
Msg("HTTP failed to start TCP4 listener")
|
||||||
emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listener go routine
|
// Start listener go routine
|
||||||
go serve(ctx)
|
go s.serve(ctx)
|
||||||
|
readyFunc()
|
||||||
|
|
||||||
// Wait for shutdown
|
// Wait for shutdown
|
||||||
select {
|
select {
|
||||||
@@ -131,6 +171,7 @@ func Start(ctx context.Context) {
|
|||||||
|
|
||||||
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||||
o := &jsonAppConfig{
|
o := &jsonAppConfig{
|
||||||
|
BasePath: webConfig.BasePath,
|
||||||
MonitorVisible: webConfig.MonitorVisible,
|
MonitorVisible: webConfig.MonitorVisible,
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(o)
|
b, err := json.Marshal(o)
|
||||||
@@ -146,7 +187,7 @@ func appConfigCookie(webConfig config.Web) *http.Cookie {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serve begins serving HTTP requests
|
// serve begins serving HTTP requests
|
||||||
func serve(ctx context.Context) {
|
func (s *Server) serve(ctx context.Context) {
|
||||||
// server.Serve blocks until we close the listener
|
// server.Serve blocks until we close the listener
|
||||||
err := server.Serve(listener)
|
err := server.Serve(listener)
|
||||||
|
|
||||||
@@ -156,16 +197,13 @@ func serve(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
Msg("HTTP server failed")
|
Msg("HTTP server failed")
|
||||||
emergencyShutdown()
|
s.notify <- err
|
||||||
|
close(s.notify)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func emergencyShutdown() {
|
// Notify allows the running Web server to be monitored for a fatal error.
|
||||||
// Shutdown Inbucket
|
func (s *Server) Notify() <-chan error {
|
||||||
select {
|
return s.notify
|
||||||
case _ = <-globalShutdown:
|
|
||||||
default:
|
|
||||||
close(globalShutdown)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -45,10 +47,11 @@ type Store struct {
|
|||||||
mailPath string
|
mailPath string
|
||||||
messageCap int
|
messageCap int
|
||||||
bufReaderPool sync.Pool
|
bufReaderPool sync.Pool
|
||||||
|
extHost *extension.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new DataStore object using the specified path
|
// New creates a new DataStore object using the specified path
|
||||||
func New(cfg config.Storage) (storage.Store, error) {
|
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
|
||||||
path := cfg.Params["path"]
|
path := cfg.Params["path"]
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, fmt.Errorf("'path' parameter not specified")
|
return nil, fmt.Errorf("'path' parameter not specified")
|
||||||
@@ -70,6 +73,7 @@ func New(cfg config.Storage) (storage.Store, error) {
|
|||||||
return bufio.NewReader(nil)
|
return bufio.NewReader(nil)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
extHost: extHost,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +187,17 @@ func (fs *Store) PurgeMessages(mailbox string) error {
|
|||||||
mb := fs.mbox(mailbox)
|
mb := fs.mbox(mailbox)
|
||||||
mb.Lock()
|
mb.Lock()
|
||||||
defer mb.Unlock()
|
defer mb.Unlock()
|
||||||
|
|
||||||
|
// Emit delete events.
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
fs.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
|
||||||
|
}
|
||||||
|
|
||||||
return mb.purge()
|
return mb.purge()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
@@ -22,8 +24,9 @@ import (
|
|||||||
|
|
||||||
// TestSuite runs storage package test suite on file store.
|
// TestSuite runs storage package test suite on file store.
|
||||||
func TestSuite(t *testing.T) {
|
func TestSuite(t *testing.T) {
|
||||||
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
test.StoreSuite(t,
|
||||||
ds, _ := setupDataStore(conf)
|
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
|
||||||
|
ds, _ := setupDataStore(conf, extHost)
|
||||||
destroy := func() {
|
destroy := func() {
|
||||||
teardownDataStore(ds)
|
teardownDataStore(ds)
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,7 @@ func TestSuite(t *testing.T) {
|
|||||||
|
|
||||||
// Test directory structure created by filestore
|
// Test directory structure created by filestore
|
||||||
func TestFSDirStructure(t *testing.T) {
|
func TestFSDirStructure(t *testing.T) {
|
||||||
ds, logbuf := setupDataStore(config.Storage{})
|
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||||
defer teardownDataStore(ds)
|
defer teardownDataStore(ds)
|
||||||
root := ds.path
|
root := ds.path
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ func TestFSDirStructure(t *testing.T) {
|
|||||||
|
|
||||||
// Test missing files
|
// Test missing files
|
||||||
func TestFSMissing(t *testing.T) {
|
func TestFSMissing(t *testing.T) {
|
||||||
ds, logbuf := setupDataStore(config.Storage{})
|
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||||
defer teardownDataStore(ds)
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
mbName := "fred"
|
mbName := "fred"
|
||||||
@@ -146,7 +149,7 @@ func TestFSMissing(t *testing.T) {
|
|||||||
|
|
||||||
// Test Get the latest message
|
// Test Get the latest message
|
||||||
func TestGetLatestMessage(t *testing.T) {
|
func TestGetLatestMessage(t *testing.T) {
|
||||||
ds, logbuf := setupDataStore(config.Storage{})
|
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||||
defer teardownDataStore(ds)
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||||
@@ -188,22 +191,25 @@ func TestGetLatestMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupDataStore creates a new FileDataStore in a temporary directory
|
// setupDataStore creates a new FileDataStore in a temporary directory
|
||||||
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
|
func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes.Buffer) {
|
||||||
path, err := ioutil.TempDir("", "inbucket")
|
path, err := ioutil.TempDir("", "inbucket")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture log output.
|
// Capture log output.
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
log.SetOutput(buf)
|
log.SetOutput(buf)
|
||||||
|
|
||||||
if cfg.Params == nil {
|
if cfg.Params == nil {
|
||||||
cfg.Params = make(map[string]string)
|
cfg.Params = make(map[string]string)
|
||||||
}
|
}
|
||||||
cfg.Params["path"] = path
|
cfg.Params["path"] = path
|
||||||
s, err := New(cfg)
|
s, err := New(cfg, extHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.(*Store), buf
|
return s.(*Store), buf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +217,7 @@ func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
|
|||||||
// the size of the generated message.
|
// the size of the generated message.
|
||||||
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
|
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
|
||||||
// Build message for delivery
|
// Build message for delivery
|
||||||
meta := message.Metadata{
|
meta := event.MessageMetadata{
|
||||||
Mailbox: mbName,
|
Mailbox: mbName,
|
||||||
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
|
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
|
||||||
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
|
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -72,6 +73,10 @@ func (mb *mbox) removeMessage(id string) error {
|
|||||||
msg = m
|
msg = m
|
||||||
// Slice around message we are deleting
|
// Slice around message we are deleting
|
||||||
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
|
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
|
||||||
|
|
||||||
|
// Emit deleted event.
|
||||||
|
mb.store.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(msg))
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ type Store struct {
|
|||||||
cap int // Per-mailbox message cap.
|
cap int // Per-mailbox message cap.
|
||||||
incoming chan *msgDone // New messages for size enforcer.
|
incoming chan *msgDone // New messages for size enforcer.
|
||||||
remove chan *msgDone // Remove deleted messages from size enforcer.
|
remove chan *msgDone // Remove deleted messages from size enforcer.
|
||||||
|
extHost *extension.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
type mbox struct {
|
type mbox struct {
|
||||||
@@ -31,10 +34,11 @@ type mbox struct {
|
|||||||
var _ storage.Store = &Store{}
|
var _ storage.Store = &Store{}
|
||||||
|
|
||||||
// New returns an emtpy memory store.
|
// New returns an emtpy memory store.
|
||||||
func New(cfg config.Storage) (storage.Store, error) {
|
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
|
||||||
s := &Store{
|
s := &Store{
|
||||||
boxes: make(map[string]*mbox),
|
boxes: make(map[string]*mbox),
|
||||||
cap: cfg.MailboxMsgCap,
|
cap: cfg.MailboxMsgCap,
|
||||||
|
extHost: extHost,
|
||||||
}
|
}
|
||||||
if str, ok := cfg.Params["maxkb"]; ok {
|
if str, ok := cfg.Params["maxkb"]; ok {
|
||||||
maxKB, err := strconv.ParseInt(str, 10, 64)
|
maxKB, err := strconv.ParseInt(str, 10, 64)
|
||||||
@@ -140,16 +144,25 @@ func (s *Store) MarkSeen(mailbox, id string) error {
|
|||||||
|
|
||||||
// PurgeMessages deletes the contents of a mailbox.
|
// PurgeMessages deletes the contents of a mailbox.
|
||||||
func (s *Store) PurgeMessages(mailbox string) error {
|
func (s *Store) PurgeMessages(mailbox string) error {
|
||||||
|
// Grab lock, copy messages, clear, and drop lock.
|
||||||
var messages map[string]*Message
|
var messages map[string]*Message
|
||||||
s.withMailbox(mailbox, true, func(mb *mbox) {
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
messages = mb.messages
|
messages = mb.messages
|
||||||
mb.messages = make(map[string]*Message)
|
mb.messages = make(map[string]*Message)
|
||||||
})
|
})
|
||||||
if len(messages) > 0 && s.remove != nil {
|
|
||||||
|
// Process size/quota.
|
||||||
|
if s.remove != nil {
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
s.enforcerRemove(m)
|
s.enforcerRemove(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit delete events.
|
||||||
|
for _, m := range messages {
|
||||||
|
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +176,11 @@ func (s *Store) removeMessage(mailbox, id string) *Message {
|
|||||||
delete(mb.messages, id)
|
delete(mb.messages, id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if m != nil {
|
||||||
|
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
|
||||||
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSuite runs storage package test suite on file store.
|
// TestSuite runs storage package test suite on file store.
|
||||||
func TestSuite(t *testing.T) {
|
func TestSuite(t *testing.T) {
|
||||||
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
test.StoreSuite(t,
|
||||||
s, _ := New(conf)
|
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
|
||||||
|
s, _ := New(conf, extHost)
|
||||||
destroy := func() {}
|
destroy := func() {}
|
||||||
return s, destroy, nil
|
return s, destroy, nil
|
||||||
})
|
})
|
||||||
@@ -21,12 +23,15 @@ func TestSuite(t *testing.T) {
|
|||||||
|
|
||||||
// TestMessageList verifies the operation of the global message list: mem.Store.messages.
|
// TestMessageList verifies the operation of the global message list: mem.Store.messages.
|
||||||
func TestMaxSize(t *testing.T) {
|
func TestMaxSize(t *testing.T) {
|
||||||
|
extHost := extension.NewHost()
|
||||||
maxSize := int64(2048)
|
maxSize := int64(2048)
|
||||||
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}})
|
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}}, extHost)
|
||||||
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
|
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
|
||||||
|
|
||||||
|
// Ensure capacity so we do not block population.
|
||||||
n := 10
|
n := 10
|
||||||
// total := 50
|
|
||||||
sizeChan := make(chan int64, len(boxes))
|
sizeChan := make(chan int64, len(boxes))
|
||||||
|
|
||||||
// Populate mailboxes concurrently.
|
// Populate mailboxes concurrently.
|
||||||
for _, mailbox := range boxes {
|
for _, mailbox := range boxes {
|
||||||
go func(mailbox string) {
|
go func(mailbox string) {
|
||||||
@@ -38,11 +43,13 @@ func TestMaxSize(t *testing.T) {
|
|||||||
sizeChan <- size
|
sizeChan <- size
|
||||||
}(mailbox)
|
}(mailbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for sizes.
|
// Wait for sizes.
|
||||||
sentBytesTotal := int64(0)
|
sentBytesTotal := int64(0)
|
||||||
for range boxes {
|
for range boxes {
|
||||||
sentBytesTotal += <-sizeChan
|
sentBytesTotal += <-sizeChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate actual size.
|
// Calculate actual size.
|
||||||
gotSize := int64(0)
|
gotSize := int64(0)
|
||||||
s.VisitMailboxes(func(messages []storage.Message) bool {
|
s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||||
@@ -51,6 +58,7 @@ func TestMaxSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify state. Messages are ~75 bytes each.
|
// Verify state. Messages are ~75 bytes each.
|
||||||
if gotSize < 2048-75 {
|
if gotSize < 2048-75 {
|
||||||
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
|
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
|
||||||
@@ -58,6 +66,7 @@ func TestMaxSize(t *testing.T) {
|
|||||||
if gotSize > maxSize {
|
if gotSize > maxSize {
|
||||||
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
|
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge all messages concurrently, testing for deadlocks.
|
// Purge all messages concurrently, testing for deadlocks.
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(len(boxes))
|
wg.Add(len(boxes))
|
||||||
@@ -65,7 +74,7 @@ func TestMaxSize(t *testing.T) {
|
|||||||
go func(mailbox string) {
|
go func(mailbox string) {
|
||||||
err := s.PurgeMessages(mailbox)
|
err := s.PurgeMessages(mailbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
panic(err) // Cannot call t.Fatal from non-test goroutine.
|
||||||
}
|
}
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}(mailbox)
|
}(mailbox)
|
||||||
|
|||||||
+15
-18
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
|
"context"
|
||||||
"expvar"
|
"expvar"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -50,7 +51,6 @@ func init() {
|
|||||||
|
|
||||||
// RetentionScanner looks for messages older than the configured retention period and deletes them.
|
// RetentionScanner looks for messages older than the configured retention period and deletes them.
|
||||||
type RetentionScanner struct {
|
type RetentionScanner struct {
|
||||||
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
|
||||||
retentionShutdown chan bool // Closed after the scanner has shut down
|
retentionShutdown chan bool // Closed after the scanner has shut down
|
||||||
ds Store
|
ds Store
|
||||||
retentionPeriod time.Duration
|
retentionPeriod time.Duration
|
||||||
@@ -61,10 +61,8 @@ type RetentionScanner struct {
|
|||||||
func NewRetentionScanner(
|
func NewRetentionScanner(
|
||||||
cfg config.Storage,
|
cfg config.Storage,
|
||||||
ds Store,
|
ds Store,
|
||||||
shutdownChannel chan bool,
|
|
||||||
) *RetentionScanner {
|
) *RetentionScanner {
|
||||||
rs := &RetentionScanner{
|
rs := &RetentionScanner{
|
||||||
globalShutdown: shutdownChannel,
|
|
||||||
retentionShutdown: make(chan bool),
|
retentionShutdown: make(chan bool),
|
||||||
ds: ds,
|
ds: ds,
|
||||||
retentionPeriod: cfg.RetentionPeriod,
|
retentionPeriod: cfg.RetentionPeriod,
|
||||||
@@ -76,20 +74,16 @@ func NewRetentionScanner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start up the retention scanner if retention period > 0
|
// Start up the retention scanner if retention period > 0
|
||||||
func (rs *RetentionScanner) Start() {
|
func (rs *RetentionScanner) Start(ctx context.Context) {
|
||||||
|
slog := log.With().Str("module", "storage").Logger()
|
||||||
|
|
||||||
if rs.retentionPeriod <= 0 {
|
if rs.retentionPeriod <= 0 {
|
||||||
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled")
|
slog.Info().Str("phase", "startup").Msg("Retention scanner disabled")
|
||||||
close(rs.retentionShutdown)
|
close(rs.retentionShutdown)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Info().Str("phase", "startup").Str("module", "storage").
|
slog.Info().Str("phase", "startup").Msgf("Retention configured for %v", rs.retentionPeriod)
|
||||||
Msgf("Retention configured for %v", rs.retentionPeriod)
|
|
||||||
go rs.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// run loops to kick off the scanner on the correct schedule
|
|
||||||
func (rs *RetentionScanner) run() {
|
|
||||||
slog := log.With().Str("module", "storage").Logger()
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
retentionLoop:
|
retentionLoop:
|
||||||
for {
|
for {
|
||||||
@@ -99,19 +93,19 @@ retentionLoop:
|
|||||||
dur := time.Minute - since
|
dur := time.Minute - since
|
||||||
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
|
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
|
||||||
select {
|
select {
|
||||||
case <-rs.globalShutdown:
|
case <-ctx.Done():
|
||||||
break retentionLoop
|
break retentionLoop
|
||||||
case <-time.After(dur):
|
case <-time.After(dur):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Kickoff scan
|
// Kickoff scan
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
if err := rs.DoScan(); err != nil {
|
if err := rs.DoScan(ctx); err != nil {
|
||||||
slog.Error().Err(err).Msg("Error during retention scan")
|
slog.Error().Err(err).Msg("Error during retention scan")
|
||||||
}
|
}
|
||||||
// Check for global shutdown
|
// Check for global shutdown
|
||||||
select {
|
select {
|
||||||
case <-rs.globalShutdown:
|
case <-ctx.Done():
|
||||||
break retentionLoop
|
break retentionLoop
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
@@ -121,13 +115,14 @@ retentionLoop:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DoScan does a single pass of all mailboxes looking for messages that can be purged.
|
// DoScan does a single pass of all mailboxes looking for messages that can be purged.
|
||||||
func (rs *RetentionScanner) DoScan() error {
|
func (rs *RetentionScanner) DoScan(ctx context.Context) error {
|
||||||
slog := log.With().Str("module", "storage").Logger()
|
slog := log.With().Str("module", "storage").Logger()
|
||||||
slog.Debug().Msg("Starting retention scan")
|
slog.Debug().Msg("Starting retention scan")
|
||||||
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
||||||
|
|
||||||
|
// Loop over all mailboxes.
|
||||||
retained := 0
|
retained := 0
|
||||||
storeSize := int64(0)
|
storeSize := int64(0)
|
||||||
// Loop over all mailboxes.
|
|
||||||
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
|
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
if msg.Date().Before(cutoff) {
|
if msg.Date().Before(cutoff) {
|
||||||
@@ -145,7 +140,7 @@ func (rs *RetentionScanner) DoScan() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-rs.globalShutdown:
|
case <-ctx.Done():
|
||||||
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
|
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
|
||||||
return false
|
return false
|
||||||
case <-time.After(rs.retentionSleep):
|
case <-time.After(rs.retentionSleep):
|
||||||
@@ -156,10 +151,12 @@ func (rs *RetentionScanner) DoScan() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update metrics
|
// Update metrics
|
||||||
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||||
expRetainedCurrent.Set(int64(retained))
|
expRetainedCurrent.Set(int64(retained))
|
||||||
expRetainedSize.Set(storeSize)
|
expRetainedSize.Set(storeSize)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package storage_test
|
package storage_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
|
|
||||||
func TestDoRetentionScan(t *testing.T) {
|
func TestDoRetentionScan(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
|
|
||||||
// Mockup some different aged messages (num is in hours)
|
// Mockup some different aged messages (num is in hours)
|
||||||
new1 := stubMessage("mb1", 0)
|
new1 := stubMessage("mb1", 0)
|
||||||
new2 := stubMessage("mb2", 1)
|
new2 := stubMessage("mb2", 1)
|
||||||
@@ -26,22 +29,26 @@ func TestDoRetentionScan(t *testing.T) {
|
|||||||
ds.AddMessage(old3)
|
ds.AddMessage(old3)
|
||||||
ds.AddMessage(new2)
|
ds.AddMessage(new2)
|
||||||
ds.AddMessage(new3)
|
ds.AddMessage(new3)
|
||||||
|
|
||||||
// Test 4 hour retention
|
// Test 4 hour retention
|
||||||
cfg := config.Storage{
|
cfg := config.Storage{
|
||||||
RetentionPeriod: 239 * time.Minute,
|
RetentionPeriod: 239 * time.Minute,
|
||||||
RetentionSleep: 0,
|
RetentionSleep: 0,
|
||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
rs := storage.NewRetentionScanner(cfg, ds, shutdownChan)
|
defer cancel()
|
||||||
if err := rs.DoScan(); err != nil {
|
rs := storage.NewRetentionScanner(cfg, ds)
|
||||||
|
if err := rs.DoScan(ctx); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete should not have been called on new messages
|
// Delete should not have been called on new messages
|
||||||
for _, m := range []storage.Message{new1, new2, new3} {
|
for _, m := range []storage.Message{new1, new2, new3} {
|
||||||
if ds.MessageDeleted(m) {
|
if ds.MessageDeleted(m) {
|
||||||
t.Errorf("Expected %v to be present, was deleted", m.ID())
|
t.Errorf("Expected %v to be present, was deleted", m.ID())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete should have been called once on old messages
|
// Delete should have been called once on old messages
|
||||||
for _, m := range []storage.Message{old1, old2, old3} {
|
for _, m := range []storage.Message{old1, old2, old3} {
|
||||||
if !ds.MessageDeleted(m) {
|
if !ds.MessageDeleted(m) {
|
||||||
@@ -53,7 +60,7 @@ func TestDoRetentionScan(t *testing.T) {
|
|||||||
// stubMessage creates a message stub of a specific age
|
// stubMessage creates a message stub of a specific age
|
||||||
func stubMessage(mailbox string, ageHours int) storage.Message {
|
func stubMessage(mailbox string, ageHours int) storage.Message {
|
||||||
return &message.Delivery{
|
return &message.Delivery{
|
||||||
Meta: message.Metadata{
|
Meta: event.MessageMetadata{
|
||||||
Mailbox: mailbox,
|
Mailbox: mailbox,
|
||||||
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
|
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
|
||||||
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
|
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -19,7 +20,7 @@ var (
|
|||||||
ErrNotWritable = errors.New("Message not writable")
|
ErrNotWritable = errors.New("Message not writable")
|
||||||
|
|
||||||
// Constructors tracks registered storage constructors
|
// Constructors tracks registered storage constructors
|
||||||
Constructors = make(map[string]func(config.Storage) (Store, error))
|
Constructors = make(map[string]func(config.Storage, *extension.Host) (Store, error))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is the interface Inbucket uses to interact with storage implementations.
|
// Store is the interface Inbucket uses to interact with storage implementations.
|
||||||
@@ -48,9 +49,9 @@ type Message interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FromConfig creates an instance of the Store based on the provided configuration.
|
// FromConfig creates an instance of the Store based on the provided configuration.
|
||||||
func FromConfig(c config.Storage) (store Store, err error) {
|
func FromConfig(c config.Storage, extHost *extension.Host) (store Store, err error) {
|
||||||
if cf := Constructors[c.Type]; cf != nil {
|
if cf := Constructors[c.Type]; cf != nil {
|
||||||
return cf(c)
|
return cf(c, extHost)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
|
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
|
|||||||
slice[i] = strings.ToLower(s)
|
slice[i] = strings.ToLower(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
|
||||||
|
// The returned prefixer expects all provided paths to start with /.
|
||||||
|
func MakePathPrefixer(prefix string) func(string) string {
|
||||||
|
prefix = strings.Trim(prefix, "/")
|
||||||
|
if prefix != "" {
|
||||||
|
prefix = "/" + prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(path string) string {
|
||||||
|
return prefix + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package stringutil_test
|
package stringutil_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMakePathPrefixer(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
prefix, path, want string
|
||||||
|
}{
|
||||||
|
{prefix: "", path: "", want: ""},
|
||||||
|
{prefix: "", path: "relative", want: "relative"},
|
||||||
|
{prefix: "", path: "/qualified", want: "/qualified"},
|
||||||
|
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
|
||||||
|
{prefix: "pfx", path: "", want: "/pfx"},
|
||||||
|
{prefix: "pfx", path: "/", want: "/pfx/"},
|
||||||
|
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
|
||||||
|
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
|
||||||
|
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||||
|
{prefix: "/pfx/", path: "", want: "/pfx"},
|
||||||
|
{prefix: "/pfx/", path: "/", want: "/pfx/"},
|
||||||
|
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
|
||||||
|
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
|
||||||
|
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||||
|
{prefix: "a/b/c", path: "", want: "/a/b/c"},
|
||||||
|
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
|
||||||
|
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
|
||||||
|
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
|
||||||
|
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||||
|
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
|
||||||
|
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
|
||||||
|
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
|
||||||
|
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
|
||||||
|
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
|
||||||
|
prefixer := stringutil.MakePathPrefixer(tc.prefix)
|
||||||
|
got := prefixer(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
@@ -32,6 +33,8 @@ const (
|
|||||||
smtpHost = "127.0.0.1:2500"
|
smtpHost = "127.0.0.1:2500"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: Add suites for domain and full addressing modes.
|
||||||
|
|
||||||
func TestSuite(t *testing.T) {
|
func TestSuite(t *testing.T) {
|
||||||
stopServer, err := startServer()
|
stopServer, err := startServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,6 +49,8 @@ func TestSuite(t *testing.T) {
|
|||||||
{"basic", testBasic},
|
{"basic", testBasic},
|
||||||
{"fullname", testFullname},
|
{"fullname", testFullname},
|
||||||
{"encodedHeader", testEncodedHeader},
|
{"encodedHeader", testEncodedHeader},
|
||||||
|
{"ipv4Recipient", testIPv4Recipient},
|
||||||
|
{"ipv6Recipient", testIPv6Recipient},
|
||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, tc.test)
|
t.Run(tc.name, tc.test)
|
||||||
@@ -139,6 +144,64 @@ func testEncodedHeader(t *testing.T) {
|
|||||||
goldiff.File(t, got, "testdata", "encodedheader.golden")
|
goldiff.File(t, got, "testdata", "encodedheader.golden")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testIPv4Recipient(t *testing.T) {
|
||||||
|
client, err := client.New(restBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
from := "fromuser@inbucket.org"
|
||||||
|
to := []string{"ip4recipient@[192.168.123.123]"}
|
||||||
|
input := readTestData("no-to.txt")
|
||||||
|
|
||||||
|
// Send mail.
|
||||||
|
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm receipt.
|
||||||
|
msg, err := client.GetMessage("ip4recipient", "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", "no-to-ipv4.golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIPv6Recipient(t *testing.T) {
|
||||||
|
client, err := client.New(restBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
from := "fromuser@inbucket.org"
|
||||||
|
to := []string{"ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]"}
|
||||||
|
input := readTestData("no-to.txt")
|
||||||
|
|
||||||
|
// Send mail.
|
||||||
|
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm receipt.
|
||||||
|
msg, err := client.GetMessage("ip6recipient", "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", "no-to-ipv6.golden")
|
||||||
|
}
|
||||||
|
|
||||||
func formatMessage(m *client.Message) []byte {
|
func formatMessage(m *client.Message) []byte {
|
||||||
b := &bytes.Buffer{}
|
b := &bytes.Buffer{}
|
||||||
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
|
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
|
||||||
@@ -152,40 +215,46 @@ func formatMessage(m *client.Message) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startServer() (func(), error) {
|
func startServer() (func(), error) {
|
||||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
// TODO Move integration setup into lifecycle.
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||||
|
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
|
||||||
|
// Storage setup.
|
||||||
storage.Constructors["memory"] = mem.New
|
storage.Constructors["memory"] = mem.New
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
conf, err := config.Process()
|
conf, err := config.Process()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
svcCtx, svcCancel := context.WithCancel(context.Background())
|
||||||
shutdownChan := make(chan bool)
|
store, err := storage.FromConfig(conf.Storage, extHost)
|
||||||
store, err := storage.FromConfig(conf.Storage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rootCancel()
|
svcCancel()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
|
||||||
|
// TODO Test should not pass with unstarted msghub.
|
||||||
addrPolicy := &policy.Addressing{Config: conf}
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
|
||||||
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
|
||||||
|
|
||||||
// Start HTTP server.
|
// Start HTTP server.
|
||||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
webServer := web.NewServer(conf, mmanager, msgHub)
|
||||||
go web.Start(rootCtx)
|
go webServer.Start(svcCtx, func() {})
|
||||||
// Start SMTP server.
|
|
||||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
|
||||||
go smtpServer.Start(rootCtx)
|
|
||||||
|
|
||||||
// TODO Implmement an elegant way to determine server readiness.
|
// Start SMTP server.
|
||||||
|
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
|
||||||
|
go smtpServer.Start(svcCtx, func() {})
|
||||||
|
|
||||||
|
// TODO Use a readyFunc to determine server readiness.
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
// Shut everything down.
|
// Shut everything down.
|
||||||
close(shutdownChan)
|
svcCancel()
|
||||||
rootCancel()
|
|
||||||
smtpServer.Drain()
|
smtpServer.Drain()
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
@@ -42,14 +43,14 @@ func (m *ManagerStub) GetMessage(mailbox, id string) (*message.Message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata gets all the metadata for the specified mailbox.
|
// GetMetadata gets all the metadata for the specified mailbox.
|
||||||
func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) {
|
func (m *ManagerStub) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
|
||||||
if mailbox == "messageserr" {
|
if mailbox == "messageserr" {
|
||||||
return nil, errors.New("internal error")
|
return nil, errors.New("internal error")
|
||||||
}
|
}
|
||||||
messages := m.mailboxes[mailbox]
|
messages := m.mailboxes[mailbox]
|
||||||
metas := make([]*message.Metadata, len(messages))
|
metas := make([]*event.MessageMetadata, len(messages))
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
metas[i] = &msg.Metadata
|
metas[i] = &msg.MessageMetadata
|
||||||
}
|
}
|
||||||
return metas, nil
|
return metas, nil
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
|
|||||||
}
|
}
|
||||||
for _, msg := range m.mailboxes[mailbox] {
|
for _, msg := range m.mailboxes[mailbox] {
|
||||||
if msg.ID == id {
|
if msg.ID == id {
|
||||||
msg.Metadata.Seen = true
|
msg.MessageMetadata.Seen = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-30
@@ -10,18 +10,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/inbucket/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreFactory returns a new store for the test suite.
|
// StoreFactory returns a new store for the test suite.
|
||||||
type StoreFactory func(config.Storage) (store storage.Store, destroy func(), err error)
|
type StoreFactory func(
|
||||||
|
config.Storage, *extension.Host) (store storage.Store, destroy func(), err error)
|
||||||
|
|
||||||
// StoreSuite runs a set of general tests on the provided Store.
|
// StoreSuite runs a set of general tests on the provided Store.
|
||||||
func StoreSuite(t *testing.T, factory StoreFactory) {
|
func StoreSuite(t *testing.T, factory StoreFactory) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
test func(*testing.T, storage.Store)
|
test func(*testing.T, storage.Store, *extension.Host)
|
||||||
conf config.Storage
|
conf config.Storage
|
||||||
}{
|
}{
|
||||||
{"metadata", testMetadata, config.Storage{}},
|
{"metadata", testMetadata, config.Storage{}},
|
||||||
@@ -39,18 +44,19 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
|
|||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
store, destroy, err := factory(tc.conf)
|
extHost := extension.NewHost()
|
||||||
|
store, destroy, err := factory(tc.conf, extHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tc.test(t, store)
|
tc.test(t, store, extHost)
|
||||||
destroy()
|
destroy()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testMetadata verifies message metadata is stored and retrieved correctly.
|
// testMetadata verifies message metadata is stored and retrieved correctly.
|
||||||
func testMetadata(t *testing.T, store storage.Store) {
|
func testMetadata(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "testmailbox"
|
mailbox := "testmailbox"
|
||||||
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
|
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
|
||||||
to := []*mail.Address{
|
to := []*mail.Address{
|
||||||
@@ -61,7 +67,7 @@ func testMetadata(t *testing.T, store storage.Store) {
|
|||||||
subject := "fantastic test subject line"
|
subject := "fantastic test subject line"
|
||||||
content := "doesn't matter"
|
content := "doesn't matter"
|
||||||
delivery := &message.Delivery{
|
delivery := &message.Delivery{
|
||||||
Meta: message.Metadata{
|
Meta: event.MessageMetadata{
|
||||||
// ID and Size will be determined by the Store.
|
// ID and Size will be determined by the Store.
|
||||||
Mailbox: mailbox,
|
Mailbox: mailbox,
|
||||||
From: from,
|
From: from,
|
||||||
@@ -117,7 +123,7 @@ func testMetadata(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testContent generates some binary content and makes sure it is correctly retrieved.
|
// testContent generates some binary content and makes sure it is correctly retrieved.
|
||||||
func testContent(t *testing.T, store storage.Store) {
|
func testContent(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
content := make([]byte, 5000)
|
content := make([]byte, 5000)
|
||||||
for i := 0; i < len(content); i++ {
|
for i := 0; i < len(content); i++ {
|
||||||
content[i] = byte(i % 256)
|
content[i] = byte(i % 256)
|
||||||
@@ -130,7 +136,7 @@ func testContent(t *testing.T, store storage.Store) {
|
|||||||
date := time.Now()
|
date := time.Now()
|
||||||
subject := "fantastic test subject line"
|
subject := "fantastic test subject line"
|
||||||
delivery := &message.Delivery{
|
delivery := &message.Delivery{
|
||||||
Meta: message.Metadata{
|
Meta: event.MessageMetadata{
|
||||||
// ID and Size will be determined by the Store.
|
// ID and Size will be determined by the Store.
|
||||||
Mailbox: mailbox,
|
Mailbox: mailbox,
|
||||||
From: from,
|
From: from,
|
||||||
@@ -168,14 +174,13 @@ func testContent(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
if errors > 5 {
|
if errors > 5 {
|
||||||
t.Fatalf("Too many content errors, aborting test.")
|
t.Fatalf("Too many content errors, aborting test.")
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
|
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
|
||||||
// with a new GetMessages call each cycle.
|
// with a new GetMessages call each cycle.
|
||||||
func testDeliveryOrder(t *testing.T, store storage.Store) {
|
func testDeliveryOrder(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||||
for i, subj := range subjects {
|
for i, subj := range subjects {
|
||||||
@@ -195,7 +200,7 @@ func testDeliveryOrder(t *testing.T, store storage.Store) {
|
|||||||
|
|
||||||
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
|
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
|
||||||
// the last message sent.
|
// the last message sent.
|
||||||
func testLatest(t *testing.T, store storage.Store) {
|
func testLatest(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||||
for _, subj := range subjects {
|
for _, subj := range subjects {
|
||||||
@@ -217,14 +222,14 @@ func testLatest(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testNaming ensures the store does not enforce local part mailbox naming.
|
// testNaming ensures the store does not enforce local part mailbox naming.
|
||||||
func testNaming(t *testing.T, store storage.Store) {
|
func testNaming(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
||||||
GetAndCountMessages(t, store, "fred", 0)
|
GetAndCountMessages(t, store, "fred", 0)
|
||||||
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// testSize verifies message content size metadata values.
|
// testSize verifies message content size metadata values.
|
||||||
func testSize(t *testing.T, store storage.Store) {
|
func testSize(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"a", "br", "much longer than the others"}
|
subjects := []string{"a", "br", "much longer than the others"}
|
||||||
sentIds := make([]string, len(subjects))
|
sentIds := make([]string, len(subjects))
|
||||||
@@ -248,7 +253,7 @@ func testSize(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testSeen verifies a message can be marked as seen.
|
// testSeen verifies a message can be marked as seen.
|
||||||
func testSeen(t *testing.T, store storage.Store) {
|
func testSeen(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "lisa"
|
mailbox := "lisa"
|
||||||
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
|
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
|
||||||
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
|
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
|
||||||
@@ -284,22 +289,24 @@ func testSeen(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testDelete creates and deletes some messages.
|
// testDelete creates and deletes some messages.
|
||||||
func testDelete(t *testing.T, store storage.Store) {
|
func testDelete(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||||
for _, subj := range subjects {
|
for _, subj := range subjects {
|
||||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||||
}
|
}
|
||||||
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
|
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||||
|
|
||||||
|
// Subscribe to events.
|
||||||
|
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
|
||||||
|
|
||||||
// Delete a couple messages.
|
// Delete a couple messages.
|
||||||
err := store.RemoveMessage(mailbox, msgs[1].ID())
|
deleteIDs := []string{msgs[1].ID(), msgs[3].ID()}
|
||||||
if err != nil {
|
for _, id := range deleteIDs {
|
||||||
t.Fatal(err)
|
err := store.RemoveMessage(mailbox, id)
|
||||||
}
|
require.NoError(t, err)
|
||||||
err = store.RemoveMessage(mailbox, msgs[3].ID())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm deletion.
|
// Confirm deletion.
|
||||||
subjects = []string{"alpha", "charlie", "echo"}
|
subjects = []string{"alpha", "charlie", "echo"}
|
||||||
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
|
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||||
@@ -309,6 +316,17 @@ func testDelete(t *testing.T, store storage.Store) {
|
|||||||
t.Errorf("Got subject %q, want %q", got, want)
|
t.Errorf("Got subject %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture events and check correct IDs were emitted.
|
||||||
|
ev1, err := eventListener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
ev2, err := eventListener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
eventIDs := []string{ev1.ID, ev2.ID}
|
||||||
|
for _, id := range deleteIDs {
|
||||||
|
assert.Contains(t, eventIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
// Try appending one more.
|
// Try appending one more.
|
||||||
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
|
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
|
||||||
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
|
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
|
||||||
@@ -322,23 +340,40 @@ func testDelete(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testPurge makes sure mailboxes can be purged.
|
// testPurge makes sure mailboxes can be purged.
|
||||||
func testPurge(t *testing.T, store storage.Store) {
|
func testPurge(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||||
|
|
||||||
|
// Subscribe to events.
|
||||||
|
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
|
||||||
|
|
||||||
|
// Populate mailbox.
|
||||||
for _, subj := range subjects {
|
for _, subj := range subjects {
|
||||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||||
}
|
}
|
||||||
GetAndCountMessages(t, store, mailbox, len(subjects))
|
GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||||
|
|
||||||
// Purge and verify.
|
// Purge and verify.
|
||||||
err := store.PurgeMessages(mailbox)
|
err := store.PurgeMessages(mailbox)
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
GetAndCountMessages(t, store, mailbox, 0)
|
GetAndCountMessages(t, store, mailbox, 0)
|
||||||
|
|
||||||
|
// Confirm events emitted.
|
||||||
|
gotEvents := []*event.MessageMetadata{}
|
||||||
|
for range subjects {
|
||||||
|
ev, err := eventListener()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
gotEvents = append(gotEvents, ev)
|
||||||
|
}
|
||||||
|
assert.Equal(t, len(subjects), len(gotEvents),
|
||||||
|
"expected delete event for each message in mailbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
// testMsgCap verifies the message cap is enforced.
|
// testMsgCap verifies the message cap is enforced.
|
||||||
func testMsgCap(t *testing.T, store storage.Store) {
|
func testMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mbCap := 10
|
mbCap := 10
|
||||||
mailbox := "captain"
|
mailbox := "captain"
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
@@ -365,7 +400,7 @@ func testMsgCap(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testNoMsgCap verfies a cap of 0 is not enforced.
|
// testNoMsgCap verfies a cap of 0 is not enforced.
|
||||||
func testNoMsgCap(t *testing.T, store storage.Store) {
|
func testNoMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||||
mailbox := "captain"
|
mailbox := "captain"
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
subj := fmt.Sprintf("subject %v", i)
|
||||||
@@ -376,7 +411,7 @@ func testNoMsgCap(t *testing.T, store storage.Store) {
|
|||||||
|
|
||||||
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
|
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
|
||||||
// them.
|
// them.
|
||||||
func testVisitMailboxes(t *testing.T, ds storage.Store) {
|
func testVisitMailboxes(t *testing.T, ds storage.Store, extHost *extension.Host) {
|
||||||
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
|
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
|
||||||
for _, name := range boxes {
|
for _, name := range boxes {
|
||||||
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
|
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
|
||||||
@@ -409,7 +444,7 @@ func DeliverToStore(
|
|||||||
date time.Time,
|
date time.Time,
|
||||||
) (string, int64) {
|
) (string, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
meta := message.Metadata{
|
meta := event.MessageMetadata{
|
||||||
Mailbox: mailbox,
|
Mailbox: mailbox,
|
||||||
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
|
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
|
||||||
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
|
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Mailbox: ip4recipient
|
||||||
|
From: <fromuser@inbucket.org>
|
||||||
|
To: [<ip4recipient@[192.168.123.123]>]
|
||||||
|
Subject: basic subject
|
||||||
|
Size: 198
|
||||||
|
|
||||||
|
BODY TEXT:
|
||||||
|
No-To message.
|
||||||
|
|
||||||
|
|
||||||
|
BODY HTML:
|
||||||
|
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Mailbox: ip6recipient
|
||||||
|
From: <fromuser@inbucket.org>
|
||||||
|
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
|
||||||
|
Subject: basic subject
|
||||||
|
Size: 227
|
||||||
|
|
||||||
|
BODY TEXT:
|
||||||
|
No-To message.
|
||||||
|
|
||||||
|
|
||||||
|
BODY HTML:
|
||||||
|
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
From: fromuser@inbucket.org
|
||||||
|
Subject: basic subject
|
||||||
|
|
||||||
|
No-To message.
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
with import <nixpkgs> {};
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
stdenv.mkDerivation rec {
|
let
|
||||||
name = "env";
|
scripts = {
|
||||||
env = buildEnv { name = name; paths = buildInputs; };
|
# Quick test script.
|
||||||
buildInputs = [
|
qt = pkgs.writeScriptBin "qt" ''
|
||||||
|
# Builds & starts inbucket, then sends it some test mail.
|
||||||
|
|
||||||
|
make build test inbucket || exit
|
||||||
|
(sleep 3; etc/swaks-tests/run-tests.sh >/dev/null) &
|
||||||
|
env INBUCKET_LOGLEVEL=debug ./inbucket
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
act
|
||||||
dpkg
|
dpkg
|
||||||
|
delve
|
||||||
elmPackages.elm
|
elmPackages.elm
|
||||||
|
elmPackages.elm-analyse
|
||||||
elmPackages.elm-format
|
elmPackages.elm-format
|
||||||
go
|
elmPackages.elm-json
|
||||||
|
elmPackages.elm-language-server
|
||||||
|
elmPackages.elm-test
|
||||||
|
go_1_18
|
||||||
golint
|
golint
|
||||||
nodejs-10_x
|
gopls
|
||||||
|
nodejs-16_x
|
||||||
|
nodePackages.yarn
|
||||||
rpm
|
rpm
|
||||||
swaks
|
swaks
|
||||||
|
|
||||||
|
scripts.qt
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Prevents launch errors with delve debugger.
|
||||||
|
hardeningDisable = [ "fortify" ];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@parcel/config-default",
|
||||||
|
"namers": [ "parcel-namer-rewrite" ]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:9000",
|
||||||
|
"ws": true
|
||||||
|
},
|
||||||
|
"/debug": {
|
||||||
|
"target": "http://localhost:9000"
|
||||||
|
},
|
||||||
|
"/serve": {
|
||||||
|
"target": "http://localhost:9000"
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -11,9 +11,8 @@ One time setup (assuming [Node.js] is already installed):
|
|||||||
|
|
||||||
```
|
```
|
||||||
cd $INBUCKET/ui
|
cd $INBUCKET/ui
|
||||||
npm i elm -g
|
yarn install
|
||||||
npm i
|
yarn build
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
||||||
@@ -30,15 +29,16 @@ Inbucket will start, with HTTP listening on port 9000. You may verify the web
|
|||||||
UI is functional if this is your first time building Inbucket, but your dev/test
|
UI is functional if this is your first time building Inbucket, but your dev/test
|
||||||
cycle should favor the development server below.
|
cycle should favor the development server below.
|
||||||
|
|
||||||
### Terminal 2: webpack development server
|
### Terminal 2: parcel development server
|
||||||
|
|
||||||
```
|
```
|
||||||
cd $INBUCKET/ui
|
cd $INBUCKET/ui
|
||||||
npm run dev
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
npm will start a development HTTP server listening on port 3000. You should use
|
yarn will start a development HTTP server listening on port 1234. You should
|
||||||
this server for UI development, as it features hot reload and the Elm debugger.
|
use this server for UI development, as it features hot reload and the Elm
|
||||||
|
debugger.
|
||||||
|
|
||||||
[Elm]: https://elm-lang.org
|
[Elm]: https://elm-lang.org
|
||||||
[Node.js]: https://nodejs.org
|
[Node.js]: https://nodejs.org
|
||||||
|
|||||||
+8
-8
@@ -3,25 +3,25 @@
|
|||||||
"source-directories": [
|
"source-directories": [
|
||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"elm-version": "0.19.0",
|
"elm-version": "0.19.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"direct": {
|
"direct": {
|
||||||
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||||
"basti1302/elm-human-readable-filesize": "1.1.1",
|
"basti1302/elm-human-readable-filesize": "1.2.0",
|
||||||
"elm/browser": "1.0.1",
|
"elm/browser": "1.0.2",
|
||||||
"elm/core": "1.0.2",
|
"elm/core": "1.0.5",
|
||||||
"elm/html": "1.0.0",
|
"elm/html": "1.0.0",
|
||||||
"elm/http": "2.0.0",
|
"elm/http": "2.0.0",
|
||||||
"elm/json": "1.1.2",
|
"elm/json": "1.1.3",
|
||||||
"elm/svg": "1.0.1",
|
"elm/svg": "1.0.1",
|
||||||
"elm/time": "1.0.0",
|
"elm/time": "1.0.0",
|
||||||
"elm/url": "1.0.0",
|
"elm/url": "1.0.0",
|
||||||
"jweir/sparkline": "4.0.0",
|
"jweir/sparkline": "4.0.0",
|
||||||
"ryannhg/date-format": "2.1.0"
|
"ryannhg/date-format": "2.3.0"
|
||||||
},
|
},
|
||||||
"indirect": {
|
"indirect": {
|
||||||
"elm/bytes": "1.0.3",
|
"elm/bytes": "1.0.8",
|
||||||
"elm/file": "1.0.1",
|
"elm/file": "1.0.5",
|
||||||
"elm/regex": "1.0.0",
|
"elm/regex": "1.0.0",
|
||||||
"elm/virtual-dom": "1.0.2",
|
"elm/virtual-dom": "1.0.2",
|
||||||
"myrho/elm-round": "1.0.4"
|
"myrho/elm-round": "1.0.4"
|
||||||
|
|||||||
Generated
-8396
File diff suppressed because it is too large
Load Diff
+21
-19
@@ -4,27 +4,29 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "parcel build --public-url ./",
|
||||||
"watch": "webpack --mode development --watch",
|
"start": "parcel --hmr-port 1235 src/index-dev.html",
|
||||||
"dev": "webpack-dev-server --mode development --host 0.0.0.0 --port 3000 --hot",
|
"clean": "rm -rf .parcel-cache dist elm-stuff"
|
||||||
"errors": "webpack --mode development --display-error-details"
|
|
||||||
},
|
},
|
||||||
|
"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": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.3.3",
|
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||||
"@babel/preset-env": "^7.3.1",
|
"@parcel/packager-raw-url": "2.4.1",
|
||||||
"@fortawesome/fontawesome-free": "^5.7.2",
|
"@parcel/transformer-elm": "^2.2.1",
|
||||||
"@webcomponents/webcomponentsjs": "^2.2.7",
|
"@parcel/transformer-webmanifest": "2.4.1",
|
||||||
"babel-loader": "^8.0.5",
|
"@webcomponents/webcomponentsjs": "^2.5.0",
|
||||||
"css-loader": "^1.0.1",
|
"opensans-npm-webfont": "^1.0.0",
|
||||||
"elm-hot-webpack-loader": "^1.0.2",
|
"parcel": "^2.4.1",
|
||||||
"elm-webpack-loader": "^5.0.0",
|
"parcel-namer-rewrite": "^2.0.0-rc.2"
|
||||||
"file-loader": "^3.0.1",
|
},
|
||||||
"html-webpack-plugin": "^3.2.0",
|
"optionalDependencies": {
|
||||||
"node-elm-compiler": "^5.0.1",
|
"elm": "^0.19.1-5"
|
||||||
"style-loader": "^0.23.1",
|
|
||||||
"webpack": "^4.29.5",
|
|
||||||
"webpack-cli": "^3.2.3",
|
|
||||||
"webpack-dev-server": "^3.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-39
@@ -1,11 +1,14 @@
|
|||||||
module Api exposing
|
module Api exposing
|
||||||
( deleteMessage
|
( DataResult
|
||||||
|
, HttpResult
|
||||||
|
, deleteMessage
|
||||||
, getGreeting
|
, getGreeting
|
||||||
, getHeaderList
|
, getHeaderList
|
||||||
, getMessage
|
, getMessage
|
||||||
, getServerConfig
|
, getServerConfig
|
||||||
, getServerMetrics
|
, getServerMetrics
|
||||||
, markMessageSeen
|
, markMessageSeen
|
||||||
|
, monitorUri
|
||||||
, purgeMailbox
|
, purgeMailbox
|
||||||
, serveUrl
|
, serveUrl
|
||||||
)
|
)
|
||||||
@@ -14,10 +17,12 @@ import Data.Message as Message exposing (Message)
|
|||||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||||
import Data.Metrics as Metrics exposing (Metrics)
|
import Data.Metrics as Metrics exposing (Metrics)
|
||||||
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
||||||
|
import Data.Session exposing (Session)
|
||||||
import Http
|
import Http
|
||||||
import HttpUtil
|
import HttpUtil
|
||||||
import Json.Decode as Decode
|
import Json.Decode as Decode
|
||||||
import Json.Encode as Encode
|
import Json.Encode as Encode
|
||||||
|
import String
|
||||||
import Url.Builder
|
import Url.Builder
|
||||||
|
|
||||||
|
|
||||||
@@ -29,31 +34,17 @@ type alias HttpResult msg =
|
|||||||
Result HttpUtil.Error () -> msg
|
Result HttpUtil.Error () -> msg
|
||||||
|
|
||||||
|
|
||||||
{-| Builds a public REST API URL (see wiki).
|
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||||
-}
|
deleteMessage session msg mailboxName id =
|
||||||
apiV1Url : List String -> String
|
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||||
apiV1Url elements =
|
|
||||||
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
|
|
||||||
|
|
||||||
|
|
||||||
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
|
||||||
-}
|
getHeaderList session msg mailboxName =
|
||||||
serveUrl : List String -> String
|
|
||||||
serveUrl elements =
|
|
||||||
Url.Builder.absolute ([ "serve" ] ++ elements) []
|
|
||||||
|
|
||||||
|
|
||||||
deleteMessage : HttpResult msg -> String -> String -> Cmd msg
|
|
||||||
deleteMessage msg mailboxName id =
|
|
||||||
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName, id ])
|
|
||||||
|
|
||||||
|
|
||||||
getHeaderList : DataResult msg (List MessageHeader) -> String -> Cmd msg
|
|
||||||
getHeaderList msg mailboxName =
|
|
||||||
let
|
let
|
||||||
context =
|
context =
|
||||||
{ method = "GET"
|
{ method = "GET"
|
||||||
, url = apiV1Url [ "mailbox", mailboxName ]
|
, url = apiV1Url session [ "mailbox", mailboxName ]
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
Http.get
|
Http.get
|
||||||
@@ -62,12 +53,12 @@ getHeaderList msg mailboxName =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getGreeting : DataResult msg String -> Cmd msg
|
getGreeting : Session -> DataResult msg String -> Cmd msg
|
||||||
getGreeting msg =
|
getGreeting session msg =
|
||||||
let
|
let
|
||||||
context =
|
context =
|
||||||
{ method = "GET"
|
{ method = "GET"
|
||||||
, url = serveUrl [ "greeting" ]
|
, url = serveUrl session [ "greeting" ]
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
Http.get
|
Http.get
|
||||||
@@ -76,12 +67,12 @@ getGreeting msg =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getMessage : DataResult msg Message -> String -> String -> Cmd msg
|
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
|
||||||
getMessage msg mailboxName id =
|
getMessage session msg mailboxName id =
|
||||||
let
|
let
|
||||||
context =
|
context =
|
||||||
{ method = "GET"
|
{ method = "GET"
|
||||||
, url = serveUrl [ "mailbox", mailboxName, id ]
|
, url = serveUrl session [ "mailbox", mailboxName, id ]
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
Http.get
|
Http.get
|
||||||
@@ -90,12 +81,12 @@ getMessage msg mailboxName id =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getServerConfig : DataResult msg ServerConfig -> Cmd msg
|
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
|
||||||
getServerConfig msg =
|
getServerConfig session msg =
|
||||||
let
|
let
|
||||||
context =
|
context =
|
||||||
{ method = "GET"
|
{ method = "GET"
|
||||||
, url = serveUrl [ "status" ]
|
, url = serveUrl session [ "status" ]
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
Http.get
|
Http.get
|
||||||
@@ -104,12 +95,19 @@ getServerConfig msg =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getServerMetrics : DataResult msg Metrics -> Cmd msg
|
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
|
||||||
getServerMetrics msg =
|
getServerMetrics session msg =
|
||||||
let
|
let
|
||||||
context =
|
context =
|
||||||
{ method = "GET"
|
{ method = "GET"
|
||||||
, url = Url.Builder.absolute [ "debug", "vars" ] []
|
, url =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(splitBasePath session.config.basePath
|
||||||
|
++ [ "debug"
|
||||||
|
, "vars"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
Http.get
|
Http.get
|
||||||
@@ -118,15 +116,83 @@ getServerMetrics msg =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg
|
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||||
markMessageSeen msg mailboxName id =
|
markMessageSeen session msg mailboxName id =
|
||||||
-- The URL tells the API which message ID to update, so we only need to indicate the
|
-- The URL tells the API which message ID to update, so we only need to indicate the
|
||||||
-- desired change in the body.
|
-- desired change in the body.
|
||||||
Encode.object [ ( "seen", Encode.bool True ) ]
|
Encode.object [ ( "seen", Encode.bool True ) ]
|
||||||
|> Http.jsonBody
|
|> Http.jsonBody
|
||||||
|> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ])
|
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||||
|
|
||||||
|
|
||||||
purgeMailbox : HttpResult msg -> String -> Cmd msg
|
monitorUri : Session -> String
|
||||||
purgeMailbox msg mailboxName =
|
monitorUri session =
|
||||||
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ])
|
apiV2Url session [ "monitor", "messages" ]
|
||||||
|
|
||||||
|
|
||||||
|
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
|
||||||
|
purgeMailbox session msg mailboxName =
|
||||||
|
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
|
||||||
|
|
||||||
|
|
||||||
|
apiV1Url : Session -> List String -> String
|
||||||
|
apiV1Url =
|
||||||
|
apiUrl "v1"
|
||||||
|
|
||||||
|
|
||||||
|
apiV2Url : Session -> List String -> String
|
||||||
|
apiV2Url =
|
||||||
|
apiUrl "v2"
|
||||||
|
|
||||||
|
|
||||||
|
{-| Builds a public REST API URL (see wiki).
|
||||||
|
-}
|
||||||
|
apiUrl : String -> Session -> List String -> String
|
||||||
|
apiUrl version session elements =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(List.concat
|
||||||
|
[ splitBasePath session.config.basePath
|
||||||
|
, [ "api", version ]
|
||||||
|
, elements
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
||||||
|
-}
|
||||||
|
serveUrl : Session -> List String -> String
|
||||||
|
serveUrl session elements =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(List.concat
|
||||||
|
[ splitBasePath session.config.basePath
|
||||||
|
, [ "serve" ]
|
||||||
|
, elements
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Converts base path into a list of path elements.
|
||||||
|
-}
|
||||||
|
splitBasePath : String -> List String
|
||||||
|
splitBasePath path =
|
||||||
|
if path == "" then
|
||||||
|
[]
|
||||||
|
|
||||||
|
else
|
||||||
|
let
|
||||||
|
stripSlashes str =
|
||||||
|
if String.startsWith "/" str then
|
||||||
|
stripSlashes (String.dropLeft 1 str)
|
||||||
|
|
||||||
|
else if String.endsWith "/" str then
|
||||||
|
stripSlashes (String.dropRight 1 str)
|
||||||
|
|
||||||
|
else
|
||||||
|
str
|
||||||
|
|
||||||
|
newPath =
|
||||||
|
stripSlashes path
|
||||||
|
in
|
||||||
|
String.split "/" newPath
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P
|
|||||||
|
|
||||||
|
|
||||||
type alias AppConfig =
|
type alias AppConfig =
|
||||||
{ monitorVisible : Bool
|
{ basePath : String
|
||||||
|
, monitorVisible : Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
decoder : D.Decoder AppConfig
|
decoder : D.Decoder AppConfig
|
||||||
decoder =
|
decoder =
|
||||||
D.succeed AppConfig
|
D.succeed AppConfig
|
||||||
|
|> P.optional "base-path" D.string ""
|
||||||
|> P.required "monitor-visible" D.bool
|
|> P.required "monitor-visible" D.bool
|
||||||
|
|
||||||
|
|
||||||
default : AppConfig
|
default : AppConfig
|
||||||
default =
|
default =
|
||||||
AppConfig True
|
AppConfig "" True
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Data.Date exposing (date)
|
module Data.Date exposing (date)
|
||||||
|
|
||||||
import Json.Decode exposing (..)
|
import Json.Decode exposing (Decoder, int, map)
|
||||||
import Time exposing (Posix)
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
||||||
|
|
||||||
import Data.Date exposing (date)
|
import Data.Date exposing (date)
|
||||||
import Json.Decode exposing (..)
|
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||||
import Json.Decode.Pipeline exposing (..)
|
import Json.Decode.Pipeline exposing (optional, required)
|
||||||
import Time exposing (Posix)
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module Data.MessageHeader exposing (MessageHeader, decoder)
|
module Data.MessageHeader exposing (MessageHeader, decoder)
|
||||||
|
|
||||||
import Data.Date exposing (date)
|
import Data.Date exposing (date)
|
||||||
import Json.Decode exposing (..)
|
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||||
import Json.Decode.Pipeline exposing (..)
|
import Json.Decode.Pipeline exposing (optional, required)
|
||||||
import Time exposing (Posix)
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
||||||
|
|
||||||
import Data.Date exposing (date)
|
import Data.Date exposing (date)
|
||||||
import Json.Decode as Decode exposing (..)
|
import Json.Decode exposing (Decoder, int, map, string, succeed)
|
||||||
import Json.Decode.Pipeline exposing (..)
|
import Json.Decode.Pipeline exposing (requiredAt)
|
||||||
import Time exposing (Posix)
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
module Data.MonitorEvent exposing (MessageID, MonitorEvent(..), decoder)
|
||||||
|
|
||||||
|
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||||
|
import Json.Decode exposing (Decoder, andThen, fail, field, string, succeed)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
|
||||||
|
|
||||||
|
type alias MessageID =
|
||||||
|
{ mailbox : String
|
||||||
|
, id : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type MonitorEvent
|
||||||
|
= MessageStored MessageHeader
|
||||||
|
| MessageDeleted MessageID
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder MonitorEvent
|
||||||
|
decoder =
|
||||||
|
field "variant" string
|
||||||
|
|> andThen variantDecoder
|
||||||
|
|
||||||
|
|
||||||
|
variantDecoder : String -> Decoder MonitorEvent
|
||||||
|
variantDecoder variant =
|
||||||
|
case variant of
|
||||||
|
"message-deleted" ->
|
||||||
|
succeed MessageDeleted
|
||||||
|
|> required "identifier" messageIdDecoder
|
||||||
|
|
||||||
|
"message-stored" ->
|
||||||
|
succeed MessageStored
|
||||||
|
|> required "header" MessageHeader.decoder
|
||||||
|
|
||||||
|
unknown ->
|
||||||
|
fail <| "Unknown variant: " ++ unknown
|
||||||
|
|
||||||
|
|
||||||
|
messageIdDecoder : Decoder MessageID
|
||||||
|
messageIdDecoder =
|
||||||
|
succeed MessageID
|
||||||
|
|> required "mailbox" string
|
||||||
|
|> required "id" string
|
||||||
@@ -15,11 +15,10 @@ module Data.Session exposing
|
|||||||
|
|
||||||
import Browser.Navigation as Nav
|
import Browser.Navigation as Nav
|
||||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||||
import Html exposing (Html)
|
|
||||||
import Json.Decode as D
|
import Json.Decode as D
|
||||||
import Json.Decode.Pipeline exposing (..)
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
import Json.Encode as E
|
import Json.Encode as E
|
||||||
import Ports
|
import Route exposing (Router)
|
||||||
import Time
|
import Time
|
||||||
import Url exposing (Url)
|
import Url exposing (Url)
|
||||||
|
|
||||||
@@ -29,6 +28,7 @@ type alias Session =
|
|||||||
, host : String
|
, host : String
|
||||||
, flash : Maybe Flash
|
, flash : Maybe Flash
|
||||||
, routing : Bool
|
, routing : Bool
|
||||||
|
, router : Router
|
||||||
, zone : Time.Zone
|
, zone : Time.Zone
|
||||||
, config : AppConfig
|
, config : AppConfig
|
||||||
, persistent : Persistent
|
, persistent : Persistent
|
||||||
@@ -52,6 +52,7 @@ init key location config persistent =
|
|||||||
, host = location.host
|
, host = location.host
|
||||||
, flash = Nothing
|
, flash = Nothing
|
||||||
, routing = True
|
, routing = True
|
||||||
|
, router = Route.newRouter config.basePath
|
||||||
, zone = Time.utc
|
, zone = Time.utc
|
||||||
, config = config
|
, config = config
|
||||||
, persistent = persistent
|
, persistent = persistent
|
||||||
@@ -64,6 +65,7 @@ initError key location error =
|
|||||||
, host = location.host
|
, host = location.host
|
||||||
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
||||||
, routing = True
|
, routing = True
|
||||||
|
, router = Route.newRouter ""
|
||||||
, zone = Time.utc
|
, zone = Time.utc
|
||||||
, config = AppConfig.default
|
, config = AppConfig.default
|
||||||
, persistent = Persistent []
|
, persistent = Persistent []
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
module Effect exposing
|
||||||
|
( Effect
|
||||||
|
, addRecent
|
||||||
|
, append
|
||||||
|
, batch
|
||||||
|
, clearFlash
|
||||||
|
, deleteMessage
|
||||||
|
, disableRouting
|
||||||
|
, enableRouting
|
||||||
|
, focusModal
|
||||||
|
, focusModalResult
|
||||||
|
, getGreeting
|
||||||
|
, getHeaderList
|
||||||
|
, getMessage
|
||||||
|
, getServerConfig
|
||||||
|
, getServerMetrics
|
||||||
|
, map
|
||||||
|
, markMessageSeen
|
||||||
|
, navigateRoute
|
||||||
|
, none
|
||||||
|
, perform
|
||||||
|
, posixTime
|
||||||
|
, purgeMailbox
|
||||||
|
, schedule
|
||||||
|
, showFlash
|
||||||
|
, updateRoute
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api exposing (DataResult, HttpResult)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Data.Message exposing (Message)
|
||||||
|
import Data.MessageHeader exposing (MessageHeader)
|
||||||
|
import Data.Metrics exposing (Metrics)
|
||||||
|
import Data.ServerConfig exposing (ServerConfig)
|
||||||
|
import Data.Session as Session exposing (Session)
|
||||||
|
import Modal
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Task
|
||||||
|
import Time
|
||||||
|
import Timer exposing (Timer)
|
||||||
|
|
||||||
|
|
||||||
|
type Effect msg
|
||||||
|
= None
|
||||||
|
| ApiEffect (ApiEffect msg)
|
||||||
|
| Batch (List (Effect msg))
|
||||||
|
| ModalFocus (Modal.Msg -> msg)
|
||||||
|
| PosixTime (Time.Posix -> msg)
|
||||||
|
| RouteNavigate Bool Route
|
||||||
|
| RouteUpdate Route
|
||||||
|
| ScheduleTimer (Timer -> msg) Timer Float
|
||||||
|
| SessionEffect SessionEffect
|
||||||
|
|
||||||
|
|
||||||
|
type ApiEffect msg
|
||||||
|
= DeleteMessage (HttpResult msg) String String
|
||||||
|
| GetGreeting (DataResult msg String)
|
||||||
|
| GetServerConfig (DataResult msg ServerConfig)
|
||||||
|
| GetServerMetrics (DataResult msg Metrics)
|
||||||
|
| GetHeaderList (DataResult msg (List MessageHeader)) String
|
||||||
|
| GetMessage (DataResult msg Message) String String
|
||||||
|
| MarkMessageSeen (HttpResult msg) String String
|
||||||
|
| PurgeMailbox (HttpResult msg) String
|
||||||
|
|
||||||
|
|
||||||
|
type SessionEffect
|
||||||
|
= FlashClear
|
||||||
|
| FlashShow Session.Flash
|
||||||
|
| ModalFocusResult Modal.Msg
|
||||||
|
| RecentAdd String
|
||||||
|
| RoutingDisable
|
||||||
|
| RoutingEnable
|
||||||
|
|
||||||
|
|
||||||
|
{-| Appends a new effect to a model/effect tuple.
|
||||||
|
-}
|
||||||
|
append : Effect msg -> ( a, Effect msg ) -> ( a, Effect msg )
|
||||||
|
append e ( model, effect ) =
|
||||||
|
( model, batch [ effect, e ] )
|
||||||
|
|
||||||
|
|
||||||
|
{-| Packs a List of Effects into a single Effect
|
||||||
|
-}
|
||||||
|
batch : List (Effect msg) -> Effect msg
|
||||||
|
batch effects =
|
||||||
|
Batch effects
|
||||||
|
|
||||||
|
|
||||||
|
{-| Transform message types produced by an effect.
|
||||||
|
-}
|
||||||
|
map : (a -> b) -> Effect a -> Effect b
|
||||||
|
map f effect =
|
||||||
|
case effect of
|
||||||
|
None ->
|
||||||
|
None
|
||||||
|
|
||||||
|
Batch effects ->
|
||||||
|
Batch <| List.map (map f) effects
|
||||||
|
|
||||||
|
ModalFocus toMsg ->
|
||||||
|
ModalFocus <| toMsg >> f
|
||||||
|
|
||||||
|
PosixTime toMsg ->
|
||||||
|
PosixTime <| toMsg >> f
|
||||||
|
|
||||||
|
ScheduleTimer toMsg timer millis ->
|
||||||
|
ScheduleTimer (toMsg >> f) timer millis
|
||||||
|
|
||||||
|
RouteNavigate pushHistory route ->
|
||||||
|
RouteNavigate pushHistory route
|
||||||
|
|
||||||
|
RouteUpdate route ->
|
||||||
|
RouteUpdate route
|
||||||
|
|
||||||
|
ApiEffect apiEffect ->
|
||||||
|
ApiEffect <| mapApi f apiEffect
|
||||||
|
|
||||||
|
SessionEffect sessionEffect ->
|
||||||
|
SessionEffect sessionEffect
|
||||||
|
|
||||||
|
|
||||||
|
mapApi : (a -> b) -> ApiEffect a -> ApiEffect b
|
||||||
|
mapApi f effect =
|
||||||
|
case effect of
|
||||||
|
DeleteMessage result mailbox id ->
|
||||||
|
DeleteMessage (result >> f) mailbox id
|
||||||
|
|
||||||
|
GetGreeting result ->
|
||||||
|
GetGreeting (result >> f)
|
||||||
|
|
||||||
|
GetServerConfig result ->
|
||||||
|
GetServerConfig (result >> f)
|
||||||
|
|
||||||
|
GetServerMetrics result ->
|
||||||
|
GetServerMetrics (result >> f)
|
||||||
|
|
||||||
|
GetHeaderList result mailbox ->
|
||||||
|
GetHeaderList (result >> f) mailbox
|
||||||
|
|
||||||
|
GetMessage result mailbox id ->
|
||||||
|
GetMessage (result >> f) mailbox id
|
||||||
|
|
||||||
|
MarkMessageSeen result mailbox id ->
|
||||||
|
MarkMessageSeen (result >> f) mailbox id
|
||||||
|
|
||||||
|
PurgeMailbox result mailbox ->
|
||||||
|
PurgeMailbox (result >> f) mailbox
|
||||||
|
|
||||||
|
|
||||||
|
{-| Applies an effect by updating the session and/or producing a Cmd.
|
||||||
|
-}
|
||||||
|
perform : ( Session, Effect msg ) -> ( Session, Cmd msg )
|
||||||
|
perform ( session, effect ) =
|
||||||
|
case effect of
|
||||||
|
None ->
|
||||||
|
( session, Cmd.none )
|
||||||
|
|
||||||
|
Batch effects ->
|
||||||
|
List.foldl batchPerform ( session, [] ) effects
|
||||||
|
|> Tuple.mapSecond Cmd.batch
|
||||||
|
|
||||||
|
ModalFocus toMsg ->
|
||||||
|
( session, Modal.resetFocusCmd toMsg )
|
||||||
|
|
||||||
|
PosixTime toMsg ->
|
||||||
|
( session, Task.perform toMsg Time.now )
|
||||||
|
|
||||||
|
ScheduleTimer toMsg timer millis ->
|
||||||
|
( session, Timer.schedule toMsg timer millis )
|
||||||
|
|
||||||
|
RouteNavigate pushHistory route ->
|
||||||
|
let
|
||||||
|
url =
|
||||||
|
session.router.toPath route
|
||||||
|
in
|
||||||
|
( Session.enableRouting session
|
||||||
|
, if pushHistory then
|
||||||
|
Nav.pushUrl session.key url
|
||||||
|
|
||||||
|
else
|
||||||
|
Nav.replaceUrl session.key url
|
||||||
|
)
|
||||||
|
|
||||||
|
RouteUpdate route ->
|
||||||
|
( Session.disableRouting session
|
||||||
|
, session.router.toPath route
|
||||||
|
|> Nav.replaceUrl session.key
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiEffect apiEffect ->
|
||||||
|
performApi ( session, apiEffect )
|
||||||
|
|
||||||
|
SessionEffect sessionEffect ->
|
||||||
|
performSession ( session, sessionEffect )
|
||||||
|
|
||||||
|
|
||||||
|
performApi : ( Session, ApiEffect msg ) -> ( Session, Cmd msg )
|
||||||
|
performApi ( session, effect ) =
|
||||||
|
case effect of
|
||||||
|
DeleteMessage toMsg mailbox id ->
|
||||||
|
( session, Api.deleteMessage session toMsg mailbox id )
|
||||||
|
|
||||||
|
GetGreeting toMsg ->
|
||||||
|
( session, Api.getGreeting session toMsg )
|
||||||
|
|
||||||
|
GetServerConfig toMsg ->
|
||||||
|
( session, Api.getServerConfig session toMsg )
|
||||||
|
|
||||||
|
GetServerMetrics toMsg ->
|
||||||
|
( session, Api.getServerMetrics session toMsg )
|
||||||
|
|
||||||
|
GetHeaderList toMsg mailbox ->
|
||||||
|
( session, Api.getHeaderList session toMsg mailbox )
|
||||||
|
|
||||||
|
GetMessage toMsg mailbox id ->
|
||||||
|
( session, Api.getMessage session toMsg mailbox id )
|
||||||
|
|
||||||
|
MarkMessageSeen toMsg mailbox id ->
|
||||||
|
( session, Api.markMessageSeen session toMsg mailbox id )
|
||||||
|
|
||||||
|
PurgeMailbox toMsg mailbox ->
|
||||||
|
( session, Api.purgeMailbox session toMsg mailbox )
|
||||||
|
|
||||||
|
|
||||||
|
performSession : ( Session, SessionEffect ) -> ( Session, Cmd msg )
|
||||||
|
performSession ( session, effect ) =
|
||||||
|
case effect of
|
||||||
|
RecentAdd mailbox ->
|
||||||
|
( Session.addRecent mailbox session, Cmd.none )
|
||||||
|
|
||||||
|
FlashClear ->
|
||||||
|
( Session.clearFlash session, Cmd.none )
|
||||||
|
|
||||||
|
FlashShow flash ->
|
||||||
|
( Session.showFlash flash session, Cmd.none )
|
||||||
|
|
||||||
|
ModalFocusResult result ->
|
||||||
|
( Modal.updateSession result session, Cmd.none )
|
||||||
|
|
||||||
|
RoutingDisable ->
|
||||||
|
( Session.disableRouting session, Cmd.none )
|
||||||
|
|
||||||
|
RoutingEnable ->
|
||||||
|
( Session.enableRouting session, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EFFECT CONSTRUCTORS
|
||||||
|
|
||||||
|
|
||||||
|
none : Effect msg
|
||||||
|
none =
|
||||||
|
None
|
||||||
|
|
||||||
|
|
||||||
|
{-| Adds specified mailbox to the recently viewed list
|
||||||
|
-}
|
||||||
|
addRecent : String -> Effect msg
|
||||||
|
addRecent mailbox =
|
||||||
|
SessionEffect (RecentAdd mailbox)
|
||||||
|
|
||||||
|
|
||||||
|
disableRouting : Effect msg
|
||||||
|
disableRouting =
|
||||||
|
SessionEffect RoutingDisable
|
||||||
|
|
||||||
|
|
||||||
|
enableRouting : Effect msg
|
||||||
|
enableRouting =
|
||||||
|
SessionEffect RoutingEnable
|
||||||
|
|
||||||
|
|
||||||
|
clearFlash : Effect msg
|
||||||
|
clearFlash =
|
||||||
|
SessionEffect FlashClear
|
||||||
|
|
||||||
|
|
||||||
|
showFlash : Session.Flash -> Effect msg
|
||||||
|
showFlash flash =
|
||||||
|
SessionEffect (FlashShow flash)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Locks focus to the `modal-dialog` dom ID.
|
||||||
|
-}
|
||||||
|
focusModal : (Modal.Msg -> msg) -> Effect msg
|
||||||
|
focusModal toMsg =
|
||||||
|
ModalFocus toMsg
|
||||||
|
|
||||||
|
|
||||||
|
focusModalResult : Modal.Msg -> Effect msg
|
||||||
|
focusModalResult msg =
|
||||||
|
SessionEffect (ModalFocusResult msg)
|
||||||
|
|
||||||
|
|
||||||
|
deleteMessage : HttpResult msg -> String -> String -> Effect msg
|
||||||
|
deleteMessage toMsg mailboxName id =
|
||||||
|
ApiEffect (DeleteMessage toMsg mailboxName id)
|
||||||
|
|
||||||
|
|
||||||
|
getGreeting : DataResult msg String -> Effect msg
|
||||||
|
getGreeting toMsg =
|
||||||
|
ApiEffect (GetGreeting toMsg)
|
||||||
|
|
||||||
|
|
||||||
|
getHeaderList : DataResult msg (List MessageHeader) -> String -> Effect msg
|
||||||
|
getHeaderList toMsg mailboxName =
|
||||||
|
ApiEffect (GetHeaderList toMsg mailboxName)
|
||||||
|
|
||||||
|
|
||||||
|
getServerConfig : DataResult msg ServerConfig -> Effect msg
|
||||||
|
getServerConfig toMsg =
|
||||||
|
ApiEffect (GetServerConfig toMsg)
|
||||||
|
|
||||||
|
|
||||||
|
getServerMetrics : DataResult msg Metrics -> Effect msg
|
||||||
|
getServerMetrics toMsg =
|
||||||
|
ApiEffect (GetServerMetrics toMsg)
|
||||||
|
|
||||||
|
|
||||||
|
getMessage : DataResult msg Message -> String -> String -> Effect msg
|
||||||
|
getMessage toMsg mailboxName id =
|
||||||
|
ApiEffect (GetMessage toMsg mailboxName id)
|
||||||
|
|
||||||
|
|
||||||
|
markMessageSeen : HttpResult msg -> String -> String -> Effect msg
|
||||||
|
markMessageSeen toMsg mailboxName id =
|
||||||
|
ApiEffect (MarkMessageSeen toMsg mailboxName id)
|
||||||
|
|
||||||
|
|
||||||
|
posixTime : (Time.Posix -> msg) -> Effect msg
|
||||||
|
posixTime toMsg =
|
||||||
|
PosixTime toMsg
|
||||||
|
|
||||||
|
|
||||||
|
purgeMailbox : HttpResult msg -> String -> Effect msg
|
||||||
|
purgeMailbox toMsg mailboxName =
|
||||||
|
ApiEffect (PurgeMailbox toMsg mailboxName)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Schedules a Timer to fire after the specified delay.
|
||||||
|
-}
|
||||||
|
schedule : (Timer -> msg) -> Timer -> Float -> Effect msg
|
||||||
|
schedule toMsg timer millis =
|
||||||
|
ScheduleTimer toMsg timer millis
|
||||||
|
|
||||||
|
|
||||||
|
{-| Updates the browsers displayed URL to the specified route, and triggers the route to be
|
||||||
|
handled by the frontend.
|
||||||
|
-}
|
||||||
|
navigateRoute : Bool -> Route -> Effect msg
|
||||||
|
navigateRoute pushHistory route =
|
||||||
|
RouteNavigate pushHistory route
|
||||||
|
|
||||||
|
|
||||||
|
{-| Updates the browsers displayed URL to the specified route. Does not trigger our own route
|
||||||
|
handling.
|
||||||
|
-}
|
||||||
|
updateRoute : Route -> Effect msg
|
||||||
|
updateRoute route =
|
||||||
|
RouteUpdate route
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UTILITY
|
||||||
|
|
||||||
|
|
||||||
|
batchPerform : Effect msg -> ( Session, List (Cmd msg) ) -> ( Session, List (Cmd msg) )
|
||||||
|
batchPerform effect ( session, cmds ) =
|
||||||
|
perform ( session, effect )
|
||||||
|
|> Tuple.mapSecond (\cmd -> cmd :: cmds)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
|
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
|
||||||
|
|
||||||
import Data.Session as Session
|
import Data.Session as Session
|
||||||
import Html exposing (Html, div, text)
|
|
||||||
import Http
|
import Http
|
||||||
import Json.Decode as Decode
|
import Json.Decode as Decode
|
||||||
|
|
||||||
|
|||||||
+107
-67
@@ -1,23 +1,46 @@
|
|||||||
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
||||||
|
|
||||||
import Data.Session as Session exposing (Session)
|
import Data.Session as Session exposing (Session)
|
||||||
import Html exposing (..)
|
import Effect exposing (Effect)
|
||||||
|
import Html
|
||||||
|
exposing
|
||||||
|
( Attribute
|
||||||
|
, Html
|
||||||
|
, a
|
||||||
|
, button
|
||||||
|
, div
|
||||||
|
, footer
|
||||||
|
, form
|
||||||
|
, h2
|
||||||
|
, header
|
||||||
|
, i
|
||||||
|
, input
|
||||||
|
, li
|
||||||
|
, nav
|
||||||
|
, pre
|
||||||
|
, span
|
||||||
|
, td
|
||||||
|
, text
|
||||||
|
, th
|
||||||
|
, tr
|
||||||
|
, ul
|
||||||
|
)
|
||||||
import Html.Attributes
|
import Html.Attributes
|
||||||
exposing
|
exposing
|
||||||
( attribute
|
( attribute
|
||||||
, class
|
, class
|
||||||
, classList
|
, classList
|
||||||
, href
|
, href
|
||||||
, id
|
|
||||||
, placeholder
|
, placeholder
|
||||||
, rel
|
, rel
|
||||||
, selected
|
|
||||||
, target
|
, target
|
||||||
, type_
|
, type_
|
||||||
, value
|
, value
|
||||||
)
|
)
|
||||||
import Html.Events as Events
|
import Html.Events as Events
|
||||||
import Route exposing (Route)
|
import Modal
|
||||||
|
import Route
|
||||||
|
import Timer exposing (Timer)
|
||||||
|
|
||||||
|
|
||||||
{-| Used to highlight current page in navbar.
|
{-| Used to highlight current page in navbar.
|
||||||
@@ -31,8 +54,9 @@ type Page
|
|||||||
|
|
||||||
type alias Model msg =
|
type alias Model msg =
|
||||||
{ mapMsg : Msg -> msg
|
{ mapMsg : Msg -> msg
|
||||||
, menuVisible : Bool
|
, mainMenuVisible : Bool
|
||||||
, recentVisible : Bool
|
, recentMenuVisible : Bool
|
||||||
|
, recentMenuTimer : Timer
|
||||||
, mailboxName : String
|
, mailboxName : String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +64,9 @@ type alias Model msg =
|
|||||||
init : (Msg -> msg) -> Model msg
|
init : (Msg -> msg) -> Model msg
|
||||||
init mapMsg =
|
init mapMsg =
|
||||||
{ mapMsg = mapMsg
|
{ mapMsg = mapMsg
|
||||||
, menuVisible = False
|
, mainMenuVisible = False
|
||||||
, recentVisible = False
|
, recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.empty
|
||||||
, mailboxName = ""
|
, mailboxName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,55 +76,89 @@ init mapMsg =
|
|||||||
reset : Model msg -> Model msg
|
reset : Model msg -> Model msg
|
||||||
reset model =
|
reset model =
|
||||||
{ model
|
{ model
|
||||||
| menuVisible = False
|
| mainMenuVisible = False
|
||||||
, recentVisible = False
|
, recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||||
, mailboxName = ""
|
, mailboxName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
= ClearFlash
|
= ClearFlash
|
||||||
|
| MainMenuToggled
|
||||||
|
| ModalFocused Modal.Msg
|
||||||
|
| ModalUnfocused
|
||||||
| OnMailboxNameInput String
|
| OnMailboxNameInput String
|
||||||
| OpenMailbox
|
| OpenMailbox
|
||||||
| ShowRecent Bool
|
| RecentMenuMouseOver
|
||||||
| ToggleMenu
|
| RecentMenuMouseOut
|
||||||
|
| RecentMenuTimeout Timer
|
||||||
|
| RecentMenuToggled
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
|
update : Msg -> Model msg -> ( Model msg, Effect msg )
|
||||||
update msg model session =
|
update msg model =
|
||||||
case msg of
|
case msg of
|
||||||
ClearFlash ->
|
ClearFlash ->
|
||||||
( model
|
( model, Effect.clearFlash )
|
||||||
, Session.clearFlash session
|
|
||||||
, Cmd.none
|
MainMenuToggled ->
|
||||||
)
|
( { model | mainMenuVisible = not model.mainMenuVisible }, Effect.none )
|
||||||
|
|
||||||
|
ModalFocused message ->
|
||||||
|
( model, Effect.focusModalResult message )
|
||||||
|
|
||||||
|
ModalUnfocused ->
|
||||||
|
( model, Effect.focusModal (ModalFocused >> model.mapMsg) )
|
||||||
|
|
||||||
OnMailboxNameInput name ->
|
OnMailboxNameInput name ->
|
||||||
( { model | mailboxName = name }
|
( { model | mailboxName = name }, Effect.none )
|
||||||
, session
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
OpenMailbox ->
|
OpenMailbox ->
|
||||||
if model.mailboxName == "" then
|
if model.mailboxName == "" then
|
||||||
( model, session, Cmd.none )
|
( model, Effect.none )
|
||||||
|
|
||||||
else
|
else
|
||||||
( model
|
( model
|
||||||
, session
|
, Effect.navigateRoute True (Route.Mailbox model.mailboxName)
|
||||||
, Route.pushUrl session.key (Route.Mailbox model.mailboxName)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ShowRecent visible ->
|
RecentMenuMouseOver ->
|
||||||
( { model | recentVisible = visible }
|
( { model
|
||||||
, session
|
| recentMenuVisible = True
|
||||||
, Cmd.none
|
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||||
|
}
|
||||||
|
, Effect.none
|
||||||
)
|
)
|
||||||
|
|
||||||
ToggleMenu ->
|
RecentMenuMouseOut ->
|
||||||
( { model | menuVisible = not model.menuVisible }
|
let
|
||||||
, session
|
-- Keep the recent menu open for a moment even if the mouse leaves it.
|
||||||
, Cmd.none
|
newTimer =
|
||||||
|
Timer.replace model.recentMenuTimer
|
||||||
|
in
|
||||||
|
( { model
|
||||||
|
| recentMenuTimer = newTimer
|
||||||
|
}
|
||||||
|
, Effect.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
|
||||||
|
)
|
||||||
|
|
||||||
|
RecentMenuTimeout timer ->
|
||||||
|
if timer == model.recentMenuTimer then
|
||||||
|
( { model
|
||||||
|
| recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.cancel timer
|
||||||
|
}
|
||||||
|
, Effect.none
|
||||||
|
)
|
||||||
|
|
||||||
|
else
|
||||||
|
-- Timer was no longer valid.
|
||||||
|
( model, Effect.none )
|
||||||
|
|
||||||
|
RecentMenuToggled ->
|
||||||
|
( { model | recentMenuVisible = not model.recentMenuVisible }
|
||||||
|
, Effect.none
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,17 +177,17 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
|||||||
div [ class "app" ]
|
div [ class "app" ]
|
||||||
[ header []
|
[ header []
|
||||||
[ nav [ class "navbar" ]
|
[ nav [ class "navbar" ]
|
||||||
[ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> model.mapMsg) ]
|
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
|
||||||
[ i [ class "fas fa-bars" ] [] ]
|
[ i [ class "fas fa-bars" ] [] ]
|
||||||
, span [ class "navbar-brand" ]
|
, span [ class "navbar-brand" ]
|
||||||
[ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
|
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
|
||||||
, ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ]
|
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
|
||||||
[ if session.config.monitorVisible then
|
[ if session.config.monitorVisible then
|
||||||
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage
|
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
|
||||||
|
|
||||||
else
|
else
|
||||||
text ""
|
text ""
|
||||||
, navbarLink Status Route.Status [ text "Status" ] activePage
|
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
|
||||||
, navbarRecent activePage activeMailbox model session
|
, navbarRecent activePage activeMailbox model session
|
||||||
, li [ class "navbar-mailbox" ]
|
, li [ class "navbar-mailbox" ]
|
||||||
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
||||||
@@ -145,8 +204,8 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
, div [ class "navbar-bg" ] [ text "" ]
|
, div [ class "navbar-bg" ] [ text "" ]
|
||||||
, frameModal modal
|
, Modal.view (ModalUnfocused |> model.mapMsg) modal
|
||||||
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content)
|
, div [ class "page" ] (errorFlash model session.flash :: content)
|
||||||
, footer []
|
, footer []
|
||||||
[ div [ class "footer" ]
|
[ div [ class "footer" ]
|
||||||
[ externalLink "https://www.inbucket.org" "Inbucket"
|
[ externalLink "https://www.inbucket.org" "Inbucket"
|
||||||
@@ -158,18 +217,6 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
frameModal : Maybe (Html msg) -> Html msg
|
|
||||||
frameModal maybeModal =
|
|
||||||
case maybeModal of
|
|
||||||
Just modal ->
|
|
||||||
div [ class "modal-mask" ]
|
|
||||||
[ div [ class "modal well" ] [ modal ]
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
text ""
|
|
||||||
|
|
||||||
|
|
||||||
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
|
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
|
||||||
errorFlash model maybeFlash =
|
errorFlash model maybeFlash =
|
||||||
let
|
let
|
||||||
@@ -198,10 +245,10 @@ externalLink url title =
|
|||||||
a [ href url, target "_blank", rel "noopener" ] [ text title ]
|
a [ href url, target "_blank", rel "noopener" ] [ text title ]
|
||||||
|
|
||||||
|
|
||||||
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a
|
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
|
||||||
navbarLink page route linkContent activePage =
|
navbarLink page url linkContent activePage =
|
||||||
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
||||||
[ a [ Route.href route ] linkContent ]
|
[ a [ href url ] linkContent ]
|
||||||
|
|
||||||
|
|
||||||
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||||
@@ -229,29 +276,22 @@ navbarRecent page activeMailbox model session =
|
|||||||
else
|
else
|
||||||
session.persistent.recentMailboxes
|
session.persistent.recentMailboxes
|
||||||
|
|
||||||
dropdownExpanded =
|
|
||||||
if model.recentVisible then
|
|
||||||
"true"
|
|
||||||
|
|
||||||
else
|
|
||||||
"false"
|
|
||||||
|
|
||||||
recentLink mailbox =
|
recentLink mailbox =
|
||||||
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
|
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
|
||||||
in
|
in
|
||||||
li
|
li
|
||||||
[ class "navbar-dropdown-container"
|
[ class "navbar-dropdown-container"
|
||||||
, classList [ ( "navbar-active", active ) ]
|
, classList [ ( "navbar-active", active ) ]
|
||||||
, attribute "aria-haspopup" "true"
|
, attribute "aria-haspopup" "true"
|
||||||
, ariaExpanded model.recentVisible
|
, ariaExpanded model.recentMenuVisible
|
||||||
, Events.onMouseOver (ShowRecent True |> model.mapMsg)
|
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
|
||||||
, Events.onMouseOut (ShowRecent False |> model.mapMsg)
|
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
|
||||||
]
|
]
|
||||||
[ span [ class "navbar-dropdown" ]
|
[ span [ class "navbar-dropdown" ]
|
||||||
[ text title
|
[ text title
|
||||||
, button
|
, button
|
||||||
[ class "navbar-dropdown-button"
|
[ class "navbar-dropdown-button"
|
||||||
, Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg)
|
, Events.onClick (RecentMenuToggled |> model.mapMsg)
|
||||||
]
|
]
|
||||||
[ i [ class "fas fa-chevron-down" ] [] ]
|
[ i [ class "fas fa-chevron-down" ] [] ]
|
||||||
]
|
]
|
||||||
|
|||||||
+29
-21
@@ -4,7 +4,8 @@ import Browser exposing (Document, UrlRequest)
|
|||||||
import Browser.Navigation as Nav
|
import Browser.Navigation as Nav
|
||||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||||
import Data.Session as Session exposing (Session)
|
import Data.Session as Session exposing (Session)
|
||||||
import Html exposing (..)
|
import Effect exposing (Effect)
|
||||||
|
import Html exposing (Html)
|
||||||
import Json.Decode as D exposing (Value)
|
import Json.Decode as D exposing (Value)
|
||||||
import Layout
|
import Layout
|
||||||
import Page.Home as Home
|
import Page.Home as Home
|
||||||
@@ -58,6 +59,8 @@ init configValue location key =
|
|||||||
Session.initError key location (D.errorToString error)
|
Session.initError key location (D.errorToString error)
|
||||||
|
|
||||||
( subModel, _ ) =
|
( subModel, _ ) =
|
||||||
|
-- Home.init effect is discarded because this subModel will be immediately replaced
|
||||||
|
-- when we change routes to the specified location.
|
||||||
Home.init session
|
Home.init session
|
||||||
|
|
||||||
initModel =
|
initModel =
|
||||||
@@ -66,12 +69,10 @@ init configValue location key =
|
|||||||
}
|
}
|
||||||
|
|
||||||
route =
|
route =
|
||||||
Route.fromUrl location
|
session.router.fromUrl location
|
||||||
|
|
||||||
( model, cmd ) =
|
|
||||||
changeRouteTo route initModel
|
|
||||||
in
|
in
|
||||||
( model, Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ] )
|
changeRouteTo route initModel
|
||||||
|
|> Tuple.mapSecond (\cmd -> Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ])
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
@@ -167,7 +168,7 @@ updateMain msg model session =
|
|||||||
UrlChanged url ->
|
UrlChanged url ->
|
||||||
-- Responds to new browser URL.
|
-- Responds to new browser URL.
|
||||||
if session.routing then
|
if session.routing then
|
||||||
changeRouteTo (Route.fromUrl url) model
|
changeRouteTo (session.router.fromUrl url) model
|
||||||
|
|
||||||
else
|
else
|
||||||
-- Skip once, but re-enable routing.
|
-- Skip once, but re-enable routing.
|
||||||
@@ -198,20 +199,18 @@ updateMain msg model session =
|
|||||||
|
|
||||||
LayoutMsg subMsg ->
|
LayoutMsg subMsg ->
|
||||||
let
|
let
|
||||||
( layout, newSession, cmd ) =
|
( layout, effect ) =
|
||||||
Layout.update subMsg model.layout session
|
Layout.update subMsg model.layout
|
||||||
in
|
in
|
||||||
( updateSession { model | layout = layout } newSession
|
( { model | layout = layout }, effect ) |> performEffects
|
||||||
, cmd
|
|
||||||
)
|
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
updatePage msg model
|
updatePage msg model |> performEffects
|
||||||
|
|
||||||
|
|
||||||
{-| Delegate incoming messages to their respective sub-pages.
|
{-| Delegate incoming messages to their respective sub-pages.
|
||||||
-}
|
-}
|
||||||
updatePage : Msg -> Model -> ( Model, Cmd Msg )
|
updatePage : Msg -> Model -> ( Model, Effect Msg )
|
||||||
updatePage msg model =
|
updatePage msg model =
|
||||||
case ( msg, model.page ) of
|
case ( msg, model.page ) of
|
||||||
( HomeMsg subMsg, Home subModel ) ->
|
( HomeMsg subMsg, Home subModel ) ->
|
||||||
@@ -232,18 +231,19 @@ updatePage msg model =
|
|||||||
|
|
||||||
( _, _ ) ->
|
( _, _ ) ->
|
||||||
-- Disregard messages destined for the wrong page.
|
-- Disregard messages destined for the wrong page.
|
||||||
( model, Cmd.none )
|
( model, Effect.none )
|
||||||
|
|
||||||
|
|
||||||
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
|
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
|
||||||
changeRouteTo route model =
|
changeRouteTo route model =
|
||||||
let
|
let
|
||||||
session =
|
session =
|
||||||
getSession model |> Session.clearFlash
|
Session.clearFlash (getSession model)
|
||||||
|
|
||||||
newModel =
|
newModel =
|
||||||
{ model | layout = Layout.reset model.layout }
|
{ model | layout = Layout.reset model.layout }
|
||||||
in
|
in
|
||||||
|
performEffects <|
|
||||||
case route of
|
case route of
|
||||||
Route.Home ->
|
Route.Home ->
|
||||||
Home.init session
|
Home.init session
|
||||||
@@ -270,7 +270,7 @@ changeRouteTo route model =
|
|||||||
}
|
}
|
||||||
in
|
in
|
||||||
( applyToModelSession (Session.showFlash flash) newModel
|
( applyToModelSession (Session.showFlash flash) newModel
|
||||||
, Cmd.none
|
, Effect.none
|
||||||
)
|
)
|
||||||
|
|
||||||
Route.Status ->
|
Route.Status ->
|
||||||
@@ -289,6 +289,14 @@ changeRouteTo route model =
|
|||||||
|> updateWith Home HomeMsg newModel
|
|> 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 -> Session
|
||||||
getSession model =
|
getSession model =
|
||||||
case model.page of
|
case model.page of
|
||||||
@@ -332,11 +340,11 @@ updateWith :
|
|||||||
(subModel -> PageModel)
|
(subModel -> PageModel)
|
||||||
-> (subMsg -> Msg)
|
-> (subMsg -> Msg)
|
||||||
-> Model
|
-> Model
|
||||||
-> ( subModel, Cmd subMsg )
|
-> ( subModel, Effect subMsg )
|
||||||
-> ( Model, Cmd Msg )
|
-> ( Model, Effect Msg )
|
||||||
updateWith toPage toMsg model ( subModel, subCmd ) =
|
updateWith toPage toMsg model ( subModel, subEffect ) =
|
||||||
( { model | page = toPage subModel }
|
( { model | page = toPage subModel }
|
||||||
, Cmd.map toMsg subCmd
|
, Effect.map toMsg subEffect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
module Modal exposing (Msg, resetFocusCmd, updateSession, view)
|
||||||
|
|
||||||
|
import Browser.Dom as Dom
|
||||||
|
import Data.Session as Session exposing (Session)
|
||||||
|
import Html exposing (Html, div, span, text)
|
||||||
|
import Html.Attributes exposing (class, id, tabindex)
|
||||||
|
import Html.Events exposing (onFocus)
|
||||||
|
import Task
|
||||||
|
|
||||||
|
|
||||||
|
type alias Msg =
|
||||||
|
Result Dom.Error ()
|
||||||
|
|
||||||
|
|
||||||
|
{-| Creates a command to focus the modal dialog.
|
||||||
|
-}
|
||||||
|
resetFocusCmd : (Msg -> msg) -> Cmd msg
|
||||||
|
resetFocusCmd resultMsg =
|
||||||
|
Task.attempt resultMsg (Dom.focus domId)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Updates a Session with an error Flash if the resetFocusCmd failed.
|
||||||
|
-}
|
||||||
|
updateSession : Msg -> Session -> Session
|
||||||
|
updateSession result session =
|
||||||
|
case result of
|
||||||
|
Ok () ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Err (Dom.NotFound missingDomId) ->
|
||||||
|
let
|
||||||
|
flash =
|
||||||
|
{ title = "DOM element not found"
|
||||||
|
, table = [ ( "Element ID", missingDomId ) ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Session.showFlash flash session
|
||||||
|
|
||||||
|
|
||||||
|
view : msg -> Maybe (Html msg) -> Html msg
|
||||||
|
view unfocusedMsg maybeModal =
|
||||||
|
case maybeModal of
|
||||||
|
Just modal ->
|
||||||
|
div [ class "modal-mask" ]
|
||||||
|
[ span [ onFocus unfocusedMsg, tabindex 0 ] []
|
||||||
|
, div [ id domId, class "modal well", tabindex -1 ] [ modal ]
|
||||||
|
, span [ onFocus unfocusedMsg, tabindex 0 ] []
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
|
||||||
|
{-| DOM ID of the modal dialog.
|
||||||
|
-}
|
||||||
|
domId : String
|
||||||
|
domId =
|
||||||
|
"modal-dialog"
|
||||||
+9
-13
@@ -1,13 +1,11 @@
|
|||||||
module Page.Home exposing (Model, Msg, init, update, view)
|
module Page.Home exposing (Model, Msg, init, update, view)
|
||||||
|
|
||||||
import Api
|
import Data.Session exposing (Session)
|
||||||
import Data.Session as Session exposing (Session)
|
import Effect exposing (Effect)
|
||||||
import Html exposing (..)
|
import Html exposing (Html)
|
||||||
import Html.Attributes exposing (..)
|
import Html.Attributes exposing (class, property)
|
||||||
import Http
|
|
||||||
import HttpUtil
|
import HttpUtil
|
||||||
import Json.Encode as Encode
|
import Json.Encode as Encode
|
||||||
import Ports
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +18,9 @@ type alias Model =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
init : Session -> ( Model, Cmd Msg )
|
init : Session -> ( Model, Effect Msg )
|
||||||
init session =
|
init session =
|
||||||
( Model session "", Api.getGreeting GreetingLoaded )
|
( Model session "", Effect.getGreeting GreetingLoaded )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,16 +31,14 @@ type Msg
|
|||||||
= GreetingLoaded (Result HttpUtil.Error String)
|
= GreetingLoaded (Result HttpUtil.Error String)
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
update : Msg -> Model -> ( Model, Effect Msg )
|
||||||
update msg model =
|
update msg model =
|
||||||
case msg of
|
case msg of
|
||||||
GreetingLoaded (Ok greeting) ->
|
GreetingLoaded (Ok greeting) ->
|
||||||
( { model | greeting = greeting }, Cmd.none )
|
( { model | greeting = greeting }, Effect.none )
|
||||||
|
|
||||||
GreetingLoaded (Err err) ->
|
GreetingLoaded (Err err) ->
|
||||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
( model, Effect.showFlash (HttpUtil.errorFlash err) )
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user