mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Compare commits
109 Commits
v3.0.0-rc1
...
v3.1.0-bet
| 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 |
36
.github/workflows/build-and-test.yml
vendored
Normal file
36
.github/workflows/build-and-test.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build and Test
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
go-build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: [ '1.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
|
||||
51
.github/workflows/docker-build.yml
vendored
51
.github/workflows/docker-build.yml
vendored
@@ -1,15 +1,54 @@
|
||||
name: Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "develop" ]
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v1
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
repository: inbucket/inbucket
|
||||
push: false
|
||||
tag_with_ref: true
|
||||
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 }}
|
||||
|
||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -1,41 +1,39 @@
|
||||
name: Build and Release
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "develop" ]
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.15
|
||||
go-version: 1.19
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '10.x'
|
||||
- name: Setup Elm
|
||||
uses: jorelali/setup-elm@v2
|
||||
with:
|
||||
elm-version: 0.19.1
|
||||
node-version: '16.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ui/yarn.lock
|
||||
- name: Build frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
yarn install --frozen-lockfile --non-interactive
|
||||
yarn run build
|
||||
working-directory: ./ui
|
||||
- name: Test build release
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
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@v2
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,6 +30,8 @@ tags.*
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
|
||||
/.direnv
|
||||
|
||||
# Inbucket binaries
|
||||
/client
|
||||
/client.exe
|
||||
@@ -52,3 +54,7 @@ repl-temp-*
|
||||
/ui/dist/
|
||||
# Dependency directories
|
||||
/ui/node_modules
|
||||
/ui/.parcel-cache
|
||||
|
||||
# Test lua files
|
||||
/inbucket.lua
|
||||
|
||||
@@ -6,12 +6,6 @@ release:
|
||||
name: inbucket
|
||||
name_template: '{{.Tag}}'
|
||||
|
||||
brews:
|
||||
- commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
install: bin.install ""
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
@@ -28,8 +22,10 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/inbucket
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
- id: inbucket-client
|
||||
@@ -43,17 +39,18 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/client
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
|
||||
archives:
|
||||
- id: tarball
|
||||
rlcp: true
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||
.Arm }}{{ end }}'
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
@@ -61,8 +58,8 @@ archives:
|
||||
- LICENSE*
|
||||
- README*
|
||||
- CHANGELOG*
|
||||
- etc/**/*
|
||||
- ui/dist/**/*
|
||||
- etc/**
|
||||
- ui/dist/**
|
||||
- ui/greeting.html
|
||||
|
||||
nfpms:
|
||||
@@ -74,11 +71,15 @@ nfpms:
|
||||
maintainer: github@hillyerd.com
|
||||
description: All-in-one disposable webmail service.
|
||||
license: MIT
|
||||
files:
|
||||
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
||||
config_files:
|
||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||
contents:
|
||||
- src: "ui/dist/**"
|
||||
dst: "/usr/local/share/inbucket/ui"
|
||||
- src: "etc/linux/inbucket.service"
|
||||
dst: "/lib/systemd/system/inbucket.service"
|
||||
type: config|noreplace
|
||||
- src: "ui/greeting.html"
|
||||
dst: "/etc/inbucket/greeting.html"
|
||||
type: config|noreplace
|
||||
|
||||
snapshot:
|
||||
name_template: SNAPSHOT-{{ .Commit }}
|
||||
|
||||
30
.travis.yml
30
.travis.yml
@@ -1,30 +0,0 @@
|
||||
dist: bionic
|
||||
|
||||
env:
|
||||
global:
|
||||
- GO111MODULE=on
|
||||
|
||||
language: go
|
||||
|
||||
install:
|
||||
- "go get golang.org/x/lint/golint"
|
||||
- "make deps"
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- go: "1.14.x"
|
||||
- go: "1.15.x"
|
||||
- language: elm
|
||||
elm: "latest-0.19.1"
|
||||
elm_format: "latest-0.19.1"
|
||||
elm_test: "latest-0.19.1"
|
||||
node_js: "10.16.0"
|
||||
install:
|
||||
- "cd ui"
|
||||
- "npm ci"
|
||||
script:
|
||||
- "elm-format --validate ."
|
||||
- "npm run build"
|
||||
|
||||
stages:
|
||||
- test
|
||||
148
CHANGELOG.md
148
CHANGELOG.md
@@ -4,7 +4,96 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [v3.0.0-rc1]
|
||||
## [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
|
||||
@@ -15,7 +104,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
fonts
|
||||
|
||||
|
||||
## [v3.0.0-beta3]
|
||||
## [v3.0.0-beta3] - 2020-09-04
|
||||
|
||||
### Added
|
||||
- Docker `HEALTHCHECK`
|
||||
@@ -31,7 +120,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Allow empty SMTP `MAIL FROM:<>`
|
||||
|
||||
|
||||
## [v3.0.0-beta2]
|
||||
## [v3.0.0-beta2] - 2019-08-17
|
||||
|
||||
### Added
|
||||
- Ability to name mailboxes after domain of email recipient, set via
|
||||
@@ -47,7 +136,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Support for late EHLO, #141
|
||||
|
||||
|
||||
## [v3.0.0-beta1]
|
||||
## [v3.0.0-beta1] - 2019-03-14
|
||||
|
||||
### Added
|
||||
- `posix-millis` field to REST message and header responses for easier date
|
||||
@@ -60,12 +149,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Update to enmime v0.5.0
|
||||
|
||||
|
||||
## v2.1.0
|
||||
## v2.1.0 - 2018-12-15
|
||||
|
||||
No change from beta1.
|
||||
|
||||
|
||||
## [v2.1.0-beta1]
|
||||
## [v2.1.0-beta1] - 2018-10-31
|
||||
|
||||
### Added
|
||||
- Use Go 1.11 modules for reproducible builds.
|
||||
@@ -238,37 +327,48 @@ No change from beta1.
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
|
||||
|
||||
[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
|
||||
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
|
||||
## Release Checklist
|
||||
|
||||
1. Create release branch: `git flow release start 1.x.0`
|
||||
1. Create a release branch
|
||||
2. Update CHANGELOG.md:
|
||||
- Ensure *Unreleased* section is up to date
|
||||
- Rename *Unreleased* section to release name and date.
|
||||
- Rename *Unreleased* section to release name and date
|
||||
- Add new GitHub `/compare` link
|
||||
- Update previous tag version for *Unreleased*
|
||||
3. Run tests
|
||||
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release: `git flow release finish`
|
||||
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
|
||||
complete
|
||||
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||
6. Push tags and wait for
|
||||
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
|
||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
|
||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
How to Contribute
|
||||
=================
|
||||
# How to Contribute
|
||||
|
||||
Inbucket encourages third-party patches. It's valuable to know how other
|
||||
developers are using the product.
|
||||
|
||||
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
|
||||
|
||||
## Making Changes
|
||||
|
||||
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
||||
you can run `git flow feature start <topic branch name>`.
|
||||
|
||||
Without git-flow, create a topic branch from where you want to base your work:
|
||||
- This is usually the `develop` branch, example command:
|
||||
`git checkout origin/develop -b <topic branch name>`
|
||||
- Only target the `master` branch if the issue is already resolved in
|
||||
`develop`.
|
||||
Inbucket follows the regular GitHub pattern. Create a topic branch from where
|
||||
you want to base your work:
|
||||
|
||||
Once you are on your topic branch:
|
||||
|
||||
1. Make commits of logical units.
|
||||
2. Add unit tests to exercise your changes.
|
||||
3. Run the updated code through `go fmt` and `go vet`.
|
||||
4. Ensure the code builds and tests with the following commands:
|
||||
- `go clean ./...`
|
||||
- `go build ./...`
|
||||
- `go test ./...`
|
||||
3. Run `make` to test, vet and confirm your code is formatted correctly.
|
||||
If you do not have Make installed, please perform these steps manually,
|
||||
otherwise your PR will not pass our checks.
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
Thank you for contributing to Inbucket!
|
||||
|
||||
[git-flow]: https://github.com/nvie/gitflow
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,35 +1,33 @@
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
# Install build-time dependencies
|
||||
FROM golang:1.15-alpine3.12 as builder
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
|
||||
### Build frontend
|
||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||
FROM --platform=linux/amd64 node:16 as frontend
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf .parcel-cache dist elm-stuff node_modules
|
||||
RUN yarn install --frozen-lockfile --non-interactive
|
||||
RUN yarn run build
|
||||
|
||||
### Build backend
|
||||
FROM golang:1.19-alpine3.17 as backend
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
RUN make clean deps
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf dist elm-stuff node_modules
|
||||
RUN npm ci
|
||||
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
|
||||
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
|
||||
|
||||
# Build server
|
||||
WORKDIR /build
|
||||
RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
# Build frontend
|
||||
WORKDIR /build/ui
|
||||
RUN npm run build
|
||||
|
||||
# Run in minimal image
|
||||
FROM alpine:3.12
|
||||
### Run in minimal image
|
||||
FROM alpine:3.17
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=builder /build/inbucket bin
|
||||
COPY --from=builder /build/ui/dist ui
|
||||
COPY --from=backend /build/inbucket bin
|
||||
COPY --from=frontend /build/ui/dist ui
|
||||
COPY etc/docker/defaults/greeting.html defaults
|
||||
COPY etc/docker/defaults/start-inbucket.sh /
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -9,7 +9,7 @@ commands = client inbucket
|
||||
|
||||
all: clean test lint build
|
||||
|
||||
$(commands): %: cmd/%
|
||||
$(commands): %: cmd/% $(SRC)
|
||||
go build ./$<
|
||||
|
||||
clean:
|
||||
|
||||
24
README.md
24
README.md
@@ -1,7 +1,7 @@
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||
[][Docker Image]
|
||||

|
||||

|
||||
|
||||
# Inbucket
|
||||
|
||||
Inbucket is an email testing service; it will accept messages for any email
|
||||
address and make them available via web, REST and POP3 interfaces. Once
|
||||
@@ -26,9 +26,9 @@ to contribute code to the project check out [CONTRIBUTING.md].
|
||||
|
||||
## Docker
|
||||
|
||||
Inbucket has automated [Docker Image] builds via Docker Hub. The `stable` tag
|
||||
tracks our `master` branch (releases), `latest` tracks our unstable
|
||||
`development` branch.
|
||||
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||
`main` branch.
|
||||
|
||||
|
||||
## Building from Source
|
||||
@@ -38,8 +38,8 @@ You will need functioning [Go] and [Node.js] installations for this to work.
|
||||
```sh
|
||||
git clone https://github.com/inbucket/inbucket.git
|
||||
cd inbucket/ui
|
||||
npm ci
|
||||
npm run build
|
||||
yarn install
|
||||
yarn build
|
||||
cd ..
|
||||
go build ./cmd/inbucket
|
||||
```
|
||||
@@ -72,10 +72,10 @@ Inbucket is open source software released under the MIT License. The latest
|
||||
version can be found at https://github.com/inbucket/inbucket
|
||||
|
||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
|
||||
[config.md]: https://github.com/inbucket/inbucket/blob/master/doc/config.md
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||
[Configurator]: https://www.inbucket.org/configurator/
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[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/
|
||||
|
||||
@@ -15,18 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/server"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -114,36 +106,16 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure internal services.
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
shutdownChan := make(chan bool)
|
||||
store, err := storage.FromConfig(conf.Storage)
|
||||
// Configure and start internal services.
|
||||
svcCtx, svcCancel := context.WithCancel(context.Background())
|
||||
services, err := server.FullAssembly(conf)
|
||||
if err != nil {
|
||||
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
|
||||
removePIDFile(*pidfile)
|
||||
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
|
||||
}
|
||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
|
||||
// Start Retention scanner.
|
||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||
retentionScanner.Start()
|
||||
|
||||
// Configure routes and start HTTP server.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
services.Start(svcCtx, func() {
|
||||
startupLog.Debug().Msg("All services report ready")
|
||||
})
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel.
|
||||
signalLoop:
|
||||
@@ -155,24 +127,31 @@ signalLoop:
|
||||
// Shutdown requested
|
||||
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
|
||||
Msg("Received SIGINT, shutting down")
|
||||
close(shutdownChan)
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
case syscall.SIGTERM:
|
||||
// Shutdown requested
|
||||
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
||||
Msg("Received SIGTERM, shutting down")
|
||||
close(shutdownChan)
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
}
|
||||
case <-shutdownChan:
|
||||
rootCancel()
|
||||
case <-services.Notify():
|
||||
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish.
|
||||
go timedExit(*pidfile)
|
||||
smtpServer.Drain()
|
||||
pop3Server.Drain()
|
||||
retentionScanner.Join()
|
||||
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
|
||||
services.SMTPServer.Drain()
|
||||
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)
|
||||
closeLog()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ variables it supports:
|
||||
|
||||
KEY DEFAULT DESCRIPTION
|
||||
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_DOMAIN inbucket HELO domain
|
||||
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
|
||||
@@ -56,6 +57,16 @@ off with `warn` or `error`.
|
||||
- Default: `info`
|
||||
- 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
|
||||
|
||||
`INBUCKET_MAILBOXNAMING`
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# description: Developer friendly Inbucket configuration
|
||||
|
||||
export INBUCKET_LOGLEVEL="debug"
|
||||
#export INBUCKET_MAILBOXNAMING="domain"
|
||||
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# description: Launch Inbucket's docker image
|
||||
|
||||
# Docker Image Tag
|
||||
IMAGE="inbucket/inbucket"
|
||||
IMAGE="inbucket/inbucket:edge"
|
||||
|
||||
# Ports exposed on host:
|
||||
PORT_HTTP=9000
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# rest-apiv1.sh
|
||||
# description: Script to access Inbucket REST API version 1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# run-tests.sh
|
||||
# description: Generate test emails for Inbucket
|
||||
|
||||
@@ -24,7 +24,7 @@ case "$1" in
|
||||
;;
|
||||
esac
|
||||
|
||||
export SWAKS_OPT_server="127.0.0.1:2500"
|
||||
export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
|
||||
export SWAKS_OPT_to="$to@inbucket.local"
|
||||
|
||||
# Basic test
|
||||
@@ -59,3 +59,7 @@ swaks $* --data nonmime-html-inlined.raw
|
||||
|
||||
# Incorrect charset, malformed final boundary
|
||||
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
|
||||
|
||||
47
go.mod
47
go.mod
@@ -1,25 +1,42 @@
|
||||
module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
|
||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
|
||||
github.com/jhillyerd/enmime v0.8.1
|
||||
github.com/gorilla/websocket v1.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/kelseyhightower/envconfig v1.4.0
|
||||
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.4
|
||||
github.com/olekukonko/tablewriter v0.0.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.20.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/net v0.0.0-20200923182212-328152dc79b1
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.22
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/yuin/gopher-lua v1.1.0
|
||||
golang.org/x/net v0.7.0
|
||||
)
|
||||
|
||||
go 1.13
|
||||
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
|
||||
|
||||
139
go.sum
139
go.sum
@@ -2,88 +2,123 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
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/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
|
||||
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
|
||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
|
||||
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.8.1 h1:Kz4xj3sJJ4Ju8e+w/7v9H4Matv5ijPgv7UkhPf+C15I=
|
||||
github.com/jhillyerd/enmime v0.8.1/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
|
||||
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/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
|
||||
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/mattn/go-runewidth v0.0.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
|
||||
github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
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/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
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-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200923182212-328152dc79b1 h1:Iu68XRPd67wN4aRGGWwwq6bZo/25jR6uu52l/j2KkUE=
|
||||
golang.org/x/net v0.0.0-20200923182212-328152dc79b1/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
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.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
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-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
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.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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=
|
||||
|
||||
@@ -58,14 +58,20 @@ func (n *mbNaming) Decode(v string) error {
|
||||
|
||||
// Root contains global configuration, and structs with for specific sub-systems.
|
||||
type Root struct {
|
||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
|
||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||
Lua Lua
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
|
||||
SMTP SMTP
|
||||
POP3 POP3
|
||||
Web Web
|
||||
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.
|
||||
type SMTP struct {
|
||||
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||
|
||||
89
pkg/extension/async_broker.go
Normal file
89
pkg/extension/async_broker.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
101
pkg/extension/async_broker_test.go
Normal file
101
pkg/extension/async_broker_test.go
Normal file
@@ -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")
|
||||
}
|
||||
59
pkg/extension/broker.go
Normal file
59
pkg/extension/broker.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
134
pkg/extension/broker_test.go
Normal file
134
pkg/extension/broker_test.go
Normal file
@@ -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")
|
||||
}
|
||||
24
pkg/extension/event/events.go
Normal file
24
pkg/extension/event/events.go
Normal file
@@ -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
|
||||
}
|
||||
34
pkg/extension/host.go
Normal file
34
pkg/extension/host.go
Normal file
@@ -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{}}
|
||||
}
|
||||
92
pkg/extension/luahost/bind_address.go
Normal file
92
pkg/extension/luahost/bind_address.go
Normal file
@@ -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
|
||||
}
|
||||
54
pkg/extension/luahost/bind_address_test.go
Normal file
54
pkg/extension/luahost/bind_address_test.go
Normal file
@@ -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)
|
||||
}
|
||||
218
pkg/extension/luahost/bind_inbucket.go
Normal file
218
pkg/extension/luahost/bind_inbucket.go
Normal file
@@ -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
|
||||
}
|
||||
102
pkg/extension/luahost/bind_inbucket_test.go
Normal file
102
pkg/extension/luahost/bind_inbucket_test.go
Normal file
@@ -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))
|
||||
}
|
||||
119
pkg/extension/luahost/bind_message.go
Normal file
119
pkg/extension/luahost/bind_message.go
Normal file
@@ -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
|
||||
}
|
||||
91
pkg/extension/luahost/bind_message_test.go
Normal file
91
pkg/extension/luahost/bind_message_test.go
Normal file
@@ -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)
|
||||
}
|
||||
17
pkg/extension/luahost/bind_policy.go
Normal file
17
pkg/extension/luahost/bind_policy.go
Normal file
@@ -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)
|
||||
}
|
||||
195
pkg/extension/luahost/lua.go
Normal file
195
pkg/extension/luahost/lua.go
Normal file
@@ -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
|
||||
}
|
||||
166
pkg/extension/luahost/lua_test.go
Normal file
166
pkg/extension/luahost/lua_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
104
pkg/extension/luahost/pool.go
Normal file
104
pkg/extension/luahost/pool.go
Normal file
@@ -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
|
||||
}
|
||||
101
pkg/extension/luahost/pool_test.go
Normal file
101
pkg/extension/luahost/pool_test.go
Normal file
@@ -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())
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"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/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -24,7 +24,7 @@ type Manager interface {
|
||||
prefix string,
|
||||
content []byte,
|
||||
) (id string, err error)
|
||||
GetMetadata(mailbox string) ([]*Metadata, error)
|
||||
GetMetadata(mailbox string) ([]*event.MessageMetadata, error)
|
||||
GetMessage(mailbox, id string) (*Message, error)
|
||||
MarkSeen(mailbox, id string) error
|
||||
PurgeMessages(mailbox string) error
|
||||
@@ -37,7 +37,7 @@ type Manager interface {
|
||||
type StoreManager struct {
|
||||
AddrPolicy *policy.Addressing
|
||||
Store storage.Store
|
||||
Hub *msghub.Hub
|
||||
ExtHost *extension.Host
|
||||
}
|
||||
|
||||
// Deliver submits a new message to the store.
|
||||
@@ -65,9 +65,10 @@ func (s *StoreManager) Deliver(
|
||||
toaddr[i] = &torecip.Address
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
||||
delivery := &Delivery{
|
||||
Meta: Metadata{
|
||||
Meta: event.MessageMetadata{
|
||||
Mailbox: to.Mailbox,
|
||||
From: fromaddr[0],
|
||||
To: toaddr,
|
||||
@@ -80,31 +81,24 @@ func (s *StoreManager) Deliver(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Hub != nil {
|
||||
// Broadcast message information.
|
||||
broadcast := msghub.Message{
|
||||
Mailbox: to.Mailbox,
|
||||
ID: id,
|
||||
From: stringutil.StringAddress(delivery.From()),
|
||||
To: stringutil.StringAddressList(delivery.To()),
|
||||
Subject: delivery.Subject(),
|
||||
Date: delivery.Date(),
|
||||
Size: delivery.Size(),
|
||||
}
|
||||
s.Hub.Dispatch(broadcast)
|
||||
}
|
||||
|
||||
// Emit message stored event.
|
||||
event := delivery.Meta
|
||||
event.ID = id
|
||||
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metas := make([]*Metadata, len(messages))
|
||||
metas := make([]*event.MessageMetadata, len(messages))
|
||||
for i, sm := range messages {
|
||||
metas[i] = makeMetadata(sm)
|
||||
metas[i] = MakeMetadata(sm)
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
@@ -124,8 +118,8 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
|
||||
return nil, err
|
||||
}
|
||||
_ = r.Close()
|
||||
header := makeMetadata(sm)
|
||||
return &Message{Metadata: *header, env: env}, nil
|
||||
header := MakeMetadata(sm)
|
||||
return &Message{MessageMetadata: *header, env: env}, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// makeMetadata populates Metadata from a storage.Message.
|
||||
func makeMetadata(m storage.Message) *Metadata {
|
||||
return &Metadata{
|
||||
// MakeMetadata populates Metadata from a storage.Message.
|
||||
func MakeMetadata(m storage.Message) *event.MessageMetadata {
|
||||
return &event.MessageMetadata{
|
||||
Mailbox: m.Mailbox(),
|
||||
ID: m.ID(),
|
||||
From: m.From(),
|
||||
|
||||
38
pkg/message/manager_test.go
Normal file
38
pkg/message/manager_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -8,33 +8,22 @@ import (
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"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.
|
||||
type Message struct {
|
||||
Metadata
|
||||
event.MessageMetadata
|
||||
env *enmime.Envelope
|
||||
}
|
||||
|
||||
// New constructs a new Message
|
||||
func New(m Metadata, e *enmime.Envelope) *Message {
|
||||
func New(m event.MessageMetadata, e *enmime.Envelope) *Message {
|
||||
return &Message{
|
||||
Metadata: m,
|
||||
env: e,
|
||||
MessageMetadata: m,
|
||||
env: e,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +54,7 @@ func (m *Message) Text() string {
|
||||
|
||||
// Delivery is used to add a message to storage.
|
||||
type Delivery struct {
|
||||
Meta Metadata
|
||||
Meta event.MessageMetadata
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,19 @@ package msghub
|
||||
import (
|
||||
"container/ring"
|
||||
"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
|
||||
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
|
||||
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
|
||||
@@ -36,38 +29,51 @@ type Hub struct {
|
||||
// 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
|
||||
// context is canceled.
|
||||
func New(ctx context.Context, historyLen int) *Hub {
|
||||
h := &Hub{
|
||||
func New(historyLen int, extHost *extension.Host) *Hub {
|
||||
hub := &Hub{
|
||||
history: ring.New(historyLen),
|
||||
listeners: make(map[Listener]struct{}),
|
||||
opChan: make(chan func(h *Hub), opChanLen),
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Shutdown
|
||||
close(h.opChan)
|
||||
return
|
||||
case op := <-h.opChan:
|
||||
op(h)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Register an extension event listener for MessageStored.
|
||||
extHost.Events.AfterMessageStored.AddListener("msghub",
|
||||
func(msg event.MessageMetadata) {
|
||||
hub.Dispatch(msg)
|
||||
})
|
||||
|
||||
return h
|
||||
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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Shutdown
|
||||
close(hub.opChan)
|
||||
return
|
||||
case op := <-hub.opChan:
|
||||
hub.runOp(op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (hub *Hub) Dispatch(msg Message) {
|
||||
func (hub *Hub) Dispatch(msg event.MessageMetadata) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
if h.history != nil {
|
||||
// Add to history buffer
|
||||
h.history.Value = msg
|
||||
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 {
|
||||
if err := l.Receive(msg); err != nil {
|
||||
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.
|
||||
func (hub *Hub) AddListener(l Listener) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
// Playback log
|
||||
h.history.Do(func(v interface{}) {
|
||||
if v != nil {
|
||||
l.Receive(v.(Message))
|
||||
l.Receive(v.(event.MessageMetadata))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,3 +145,17 @@ func (hub *Hub) Sync() {
|
||||
}
|
||||
<-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)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@ package msghub
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"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
|
||||
type testListener struct {
|
||||
messages []*Message // received messages
|
||||
wantMessages int // how many messages this listener wants to receive
|
||||
errorAfter int // when != 0, messages until Receive() begins returning error
|
||||
messages []*event.MessageMetadata // received messages
|
||||
deletes []string // received deletes
|
||||
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
|
||||
overflow chan struct{} // closed if we receive wantMessages+1
|
||||
@@ -19,10 +27,11 @@ type testListener struct {
|
||||
|
||||
func newTestListener(want int) *testListener {
|
||||
l := &testListener{
|
||||
messages: make([]*Message, 0, want*2),
|
||||
wantMessages: want,
|
||||
done: make(chan struct{}),
|
||||
overflow: make(chan struct{}),
|
||||
messages: make([]*event.MessageMetadata, 0, want*2),
|
||||
deletes: make([]string, 0, want*2),
|
||||
wantEvents: want,
|
||||
done: make(chan struct{}),
|
||||
overflow: make(chan struct{}),
|
||||
}
|
||||
if want == 0 {
|
||||
close(l.done)
|
||||
@@ -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
|
||||
// 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)
|
||||
if len(l.messages) == l.wantMessages {
|
||||
if l.gotEvents == l.wantEvents {
|
||||
close(l.done)
|
||||
}
|
||||
if len(l.messages) == l.wantMessages+1 {
|
||||
if l.gotEvents == l.wantEvents+1 {
|
||||
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 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
|
||||
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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
hub := New(5, extension.NewHost())
|
||||
if hub == nil {
|
||||
t.Fatal("New() == nil, expected a new Hub")
|
||||
}
|
||||
@@ -63,30 +77,33 @@ func TestHubNew(t *testing.T) {
|
||||
func TestHubZeroLen(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 0)
|
||||
m := Message{}
|
||||
hub := New(0, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Just making sure Hub doesn't panic
|
||||
// Ensures Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubZeroListeners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Just making sure Hub doesn't panic
|
||||
// Ensures Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubOneListener(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
@@ -103,8 +120,9 @@ func TestHubOneListener(t *testing.T) {
|
||||
func TestHubRemoveListener(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
@@ -125,8 +143,9 @@ func TestHubRemoveListener(t *testing.T) {
|
||||
func TestHubRemoveListenerOnError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
|
||||
// error after 1 means listener should receive 2 messages before being removed
|
||||
l := newTestListener(2)
|
||||
@@ -151,14 +170,15 @@ func TestHubRemoveListenerOnError(t *testing.T) {
|
||||
func TestHubHistoryReplay(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 100)
|
||||
hub := New(100, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
l1 := newTestListener(3)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast 3 messages with no listeners
|
||||
msgs := make([]Message, 3)
|
||||
msgs := make([]event.MessageMetadata, 3)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = Message{
|
||||
msgs[i] = event.MessageMetadata{
|
||||
Subject: fmt.Sprintf("subj %v", 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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
l1 := newTestListener(20)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast more messages than the hub can hold
|
||||
msgs := make([]Message, 20)
|
||||
msgs := make([]event.MessageMetadata, 20)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = Message{
|
||||
msgs[i] = event.MessageMetadata{
|
||||
Subject: fmt.Sprintf("subj %v", 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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
|
||||
@@ -3,6 +3,7 @@ package policy
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
@@ -17,44 +18,41 @@ type Addressing struct {
|
||||
|
||||
// ExtractMailbox extracts the mailbox name from a partial email address.
|
||||
func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
||||
if a.Config.MailboxNaming == config.DomainNaming {
|
||||
return extractDomainMailbox(address)
|
||||
}
|
||||
|
||||
local, domain, err := parseEmailAddress(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
local, err = parseMailboxName(local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if a.Config.MailboxNaming == config.LocalNaming {
|
||||
return local, nil
|
||||
}
|
||||
if a.Config.MailboxNaming == config.DomainNaming {
|
||||
// If no domain is specified, assume this is being
|
||||
// used for mailbox lookup via the API.
|
||||
if domain == "" {
|
||||
if ValidateDomainPart(local) == false {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
if ValidateDomainPart(domain) == false {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
if a.Config.MailboxNaming != config.FullNaming {
|
||||
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
return local, nil
|
||||
}
|
||||
|
||||
if !ValidateDomainPart(domain) {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||
}
|
||||
|
||||
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) {
|
||||
local, domain, err := ParseEmailAddress(address)
|
||||
if err != nil {
|
||||
@@ -64,12 +62,8 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar, err := mail.ParseAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Recipient{
|
||||
Address: *ar,
|
||||
Address: mail.Address{Address: address},
|
||||
addrPolicy: a,
|
||||
LocalPart: local,
|
||||
Domain: domain,
|
||||
@@ -122,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
|
||||
// ParseEmailAddress().
|
||||
func ValidateDomainPart(domain string) bool {
|
||||
if len(domain) == 0 {
|
||||
ln := len(domain)
|
||||
if ln == 0 {
|
||||
return false
|
||||
}
|
||||
if len(domain) > 255 {
|
||||
if ln > 255 {
|
||||
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 += "."
|
||||
}
|
||||
prev := '.'
|
||||
@@ -168,6 +173,40 @@ func ValidateDomainPart(domain string) bool {
|
||||
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
|
||||
// error is returned if the local part fails validation following the guidelines in RFC3696. The
|
||||
// domain part is optional and not validated.
|
||||
@@ -178,12 +217,23 @@ func parseEmailAddress(address string) (local string, domain string, err error)
|
||||
if len(address) > 320 {
|
||||
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||
}
|
||||
|
||||
// Remove forward-path routes.
|
||||
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] == '.' {
|
||||
return "", "", fmt.Errorf("address cannot start with a period")
|
||||
}
|
||||
|
||||
// Loop over address parsing out local part.
|
||||
buf := new(bytes.Buffer)
|
||||
prev := byte('.')
|
||||
|
||||
@@ -259,6 +259,30 @@ func TestExtractMailboxValid(t *testing.T) {
|
||||
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 {
|
||||
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
|
||||
@@ -285,10 +309,42 @@ func TestExtractMailboxValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||
|
||||
// Test local mailbox naming policy.
|
||||
localInvalidTable := []struct {
|
||||
input, msg string
|
||||
@@ -303,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)
|
||||
}
|
||||
}
|
||||
|
||||
// Test full mailbox naming policy.
|
||||
fullInvalidTable := []struct {
|
||||
input, msg string
|
||||
@@ -318,6 +375,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
|
||||
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Test domain mailbox naming policy.
|
||||
domainInvalidTable := []struct {
|
||||
input, msg string
|
||||
@@ -365,6 +423,10 @@ func TestValidateDomain(t *testing.T) {
|
||||
{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", 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 {
|
||||
if policy.ValidateDomainPart(tt.input) != tt.expect {
|
||||
@@ -419,6 +481,9 @@ func TestValidateLocal(t *testing.T) {
|
||||
{"$A12345", 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"},
|
||||
{"@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 {
|
||||
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
|
||||
@@ -430,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/jhillyerd/enmime"
|
||||
@@ -67,7 +68,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
// Test JSON message headers
|
||||
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||
tzPST := time.FixedZone("PST", -8*3600)
|
||||
meta1 := message.Metadata{
|
||||
meta1 := event.MessageMetadata{
|
||||
Mailbox: "good",
|
||||
ID: "0001",
|
||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||
@@ -75,7 +76,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
Subject: "subject 1",
|
||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||
}
|
||||
meta2 := message.Metadata{
|
||||
meta2 := event.MessageMetadata{
|
||||
Mailbox: "good",
|
||||
ID: "0002",
|
||||
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||
@@ -83,8 +84,8 @@ func TestRestMailboxList(t *testing.T) {
|
||||
Subject: "subject 2",
|
||||
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||
}
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||
@@ -178,7 +179,7 @@ func TestRestMessage(t *testing.T) {
|
||||
// Test JSON message headers
|
||||
tzPST := time.FixedZone("PST", -8*3600)
|
||||
msg1 := message.New(
|
||||
message.Metadata{
|
||||
event.MessageMetadata{
|
||||
Mailbox: "good",
|
||||
ID: "0001",
|
||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||
@@ -254,7 +255,7 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
// Create some messages.
|
||||
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||
tzPST := time.FixedZone("PST", -8*3600)
|
||||
meta1 := message.Metadata{
|
||||
meta1 := event.MessageMetadata{
|
||||
Mailbox: "good",
|
||||
ID: "0001",
|
||||
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||
@@ -262,7 +263,7 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
Subject: "subject 1",
|
||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||
}
|
||||
meta2 := message.Metadata{
|
||||
meta2 := event.MessageMetadata{
|
||||
Mailbox: "good",
|
||||
ID: "0002",
|
||||
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||
@@ -270,8 +271,8 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
Subject: "subject 2",
|
||||
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||
}
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
|
||||
// Mark one read.
|
||||
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
||||
expectCode := 200
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message.
|
||||
type JSONMessageHeaderV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
|
||||
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 {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
|
||||
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||
}
|
||||
|
||||
// JSONMessageAttachmentV1 contains information about a MIME attachment
|
||||
// JSONMessageAttachmentV1 contains information about a MIME attachment.
|
||||
type JSONMessageAttachmentV1 struct {
|
||||
FileName string `json:"filename"`
|
||||
ContentType string `json:"content-type"`
|
||||
@@ -42,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
|
||||
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 {
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
|
||||
15
pkg/rest/model/apiv2_model.go
Normal file
15
pkg/rest/model/apiv2_model.go
Normal file
@@ -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")
|
||||
r.Path("/v1/monitor/messages/{name}").Handler(
|
||||
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"
|
||||
|
||||
"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/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// 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.
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
pingPeriodV1 = (pongWaitV1 * 9) / 10
|
||||
|
||||
// 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.
|
||||
maxMessageSize = 512
|
||||
maxMessageSizeV1 = 512
|
||||
)
|
||||
|
||||
// options for gorilla connection upgrader
|
||||
var upgrader = websocket.Upgrader{
|
||||
var upgraderV1 = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// msgListener handles messages from the msghub
|
||||
type msgListener struct {
|
||||
hub *msghub.Hub // Global message hub
|
||||
c chan msghub.Message // Queue of messages from Receive()
|
||||
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
||||
// msgListenerV1 handles messages from the msghub
|
||||
type msgListenerV1 struct {
|
||||
hub *msghub.Hub // Global message hub
|
||||
c chan event.MessageMetadata // Queue of messages from Receive()
|
||||
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.
|
||||
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
||||
ml := &msgListener{
|
||||
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListenerV1 {
|
||||
ml := &msgListenerV1{
|
||||
hub: hub,
|
||||
c: make(chan msghub.Message, 100),
|
||||
c: make(chan event.MessageMetadata, 100),
|
||||
mailbox: mailbox,
|
||||
}
|
||||
hub.AddListener(ml)
|
||||
return ml
|
||||
}
|
||||
|
||||
// Receive handles an incoming message
|
||||
func (ml *msgListener) Receive(msg msghub.Message) error {
|
||||
// Receive handles an incoming message.
|
||||
func (ml *msgListenerV1) Receive(msg event.MessageMetadata) error {
|
||||
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||
// Did not match mailbox name
|
||||
// Did not match the watched mailbox name.
|
||||
return nil
|
||||
}
|
||||
ml.c <- msg
|
||||
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
|
||||
func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
||||
func (ml *msgListenerV1) WSReader(conn *websocket.Conn) {
|
||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||
defer ml.Close()
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetReadLimit(maxMessageSizeV1)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWaitV1))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
slog.Debug().Msg("Got pong")
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetReadDeadline(time.Now().Add(pongWaitV1))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -92,8 +100,8 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
||||
}
|
||||
|
||||
// WSWriter makes sure the websocket client is still connected
|
||||
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
func (ml *msgListenerV1) WSWriter(conn *websocket.Conn) {
|
||||
ticker := time.NewTicker(pingPeriodV1)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
ml.Close()
|
||||
@@ -103,29 +111,19 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-ml.c:
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
|
||||
if !ok {
|
||||
// msgListener closed, exit
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
header := &model.JSONMessageHeaderV1{
|
||||
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 {
|
||||
if conn.WriteJSON(metadataToHeader(&msg)) != nil {
|
||||
// Write failed
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send ping
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
|
||||
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||
// Write error
|
||||
return
|
||||
@@ -137,7 +135,7 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
}
|
||||
|
||||
// Close removes the listener registration
|
||||
func (ml *msgListener) Close() {
|
||||
func (ml *msgListenerV1) Close() {
|
||||
select {
|
||||
case <-ml.c:
|
||||
// Already closed
|
||||
@@ -152,7 +150,7 @@ func (ml *msgListener) Close() {
|
||||
func MonitorAllMessagesV1(
|
||||
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Upgrade to Websocket.
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
conn, err := upgraderV1.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +162,7 @@ func MonitorAllMessagesV1(
|
||||
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 := newMsgListener(ctx.MsgHub, "")
|
||||
ml := newMsgListenerV1(ctx.MsgHub, "")
|
||||
go ml.WSWriter(conn)
|
||||
ml.WSReader(conn)
|
||||
return nil
|
||||
@@ -179,7 +177,7 @@ func MonitorMailboxMessagesV1(
|
||||
return err
|
||||
}
|
||||
// Upgrade to Websocket.
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
conn, err := upgraderV1.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -191,8 +189,21 @@ func MonitorMailboxMessagesV1(
|
||||
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 := newMsgListener(ctx.MsgHub, name)
|
||||
ml := newMsgListenerV1(ctx.MsgHub, name)
|
||||
go ml.WSWriter(conn)
|
||||
ml.WSReader(conn)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
214
pkg/rest/socketv2_controller.go
Normal file
214
pkg/rest/socketv2_controller.go
Normal file
@@ -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",
|
||||
},
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
web.NewServer(cfg, mm, &msghub.Hub{})
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
119
pkg/server/lifecycle.go
Normal file
119
pkg/server/lifecycle.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,47 +13,53 @@ import (
|
||||
|
||||
// Server defines an instance of the POP3 server.
|
||||
type Server struct {
|
||||
config config.POP3 // POP3 configuration.
|
||||
store storage.Store // Mail store.
|
||||
listener net.Listener // TCP listener.
|
||||
globalShutdown chan bool // Inbucket shutdown signal.
|
||||
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||
config config.POP3 // POP3 configuration.
|
||||
store storage.Store // Mail store.
|
||||
listener net.Listener // TCP listener.
|
||||
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||
notify chan error // Notify on fatal error.
|
||||
}
|
||||
|
||||
// New creates a new Server struct.
|
||||
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
|
||||
// NewServer creates a new, unstarted, POP3 server.
|
||||
func NewServer(pop3Config config.POP3, store storage.Store) *Server {
|
||||
return &Server{
|
||||
config: pop3Config,
|
||||
store: store,
|
||||
globalShutdown: shutdownChan,
|
||||
wg: new(sync.WaitGroup),
|
||||
config: pop3Config,
|
||||
store: store,
|
||||
wg: new(sync.WaitGroup),
|
||||
notify: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||
if err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
// Listener go routine.
|
||||
|
||||
// Start listener go routine.
|
||||
go s.serve(ctx)
|
||||
readyFunc()
|
||||
|
||||
// Wait for shutdown.
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
}
|
||||
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
|
||||
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit.
|
||||
if err := s.listener.Close(); err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to close POP3 listener")
|
||||
@@ -88,7 +94,8 @@ func (s *Server) serve(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
// Something went wrong.
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
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
|
||||
func (s *Server) Drain() {
|
||||
// Wait for sessions to close
|
||||
s.wg.Wait()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our SMTP state machine.
|
||||
@@ -25,10 +25,27 @@ const (
|
||||
// timeStampFormat to use in Received header.
|
||||
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||
|
||||
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
|
||||
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
|
||||
|
||||
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
|
||||
// `User Name`
|
||||
usernameChallenge = "VXNlciBOYW1lAA=="
|
||||
|
||||
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
|
||||
// `Password`
|
||||
passwordChallenge = "UGFzc3dvcmQA"
|
||||
)
|
||||
|
||||
const (
|
||||
// GREET State: Waiting for HELO
|
||||
GREET State = iota
|
||||
// READY State: Got HELO, waiting for MAIL
|
||||
READY
|
||||
// LOGIN State: Got AUTH LOGIN command, expecting Username
|
||||
LOGIN
|
||||
// PASSWORD State: Got Username, expecting password
|
||||
PASSWORD
|
||||
// MAIL State: Got MAIL, accepting RCPTs
|
||||
MAIL
|
||||
// DATA State: Got DATA, waiting for "."
|
||||
@@ -37,11 +54,11 @@ const (
|
||||
QUIT
|
||||
)
|
||||
|
||||
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
|
||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||
// (?:) is non-grouping sub-match
|
||||
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
|
||||
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
|
||||
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
|
||||
var fromRegex = regexp.MustCompile(
|
||||
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
|
||||
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( ([\w= ]|=<>)+)?$`)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
@@ -76,6 +93,7 @@ var commands = map[string]bool{
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"STARTTLS": true,
|
||||
"AUTH": true,
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
@@ -126,8 +144,8 @@ func (s *Session) String() string {
|
||||
* 4. If bad cmd, respond error
|
||||
* 5. Goto 2
|
||||
*/
|
||||
func (s *Server) startSession(id int, conn net.Conn) {
|
||||
logger := log.Hook(logHook{}).With().
|
||||
func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
|
||||
logger = logger.Hook(logHook{}).With().
|
||||
Str("module", "smtp").
|
||||
Str("remote", conn.RemoteAddr().String()).
|
||||
Int("session", id).Logger()
|
||||
@@ -153,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
line, err := ssn.readLine()
|
||||
if err == nil {
|
||||
//Handle LOGIN/PASSWORD states here, because they don't expect a command
|
||||
switch ssn.state {
|
||||
case LOGIN:
|
||||
ssn.loginHandler(line)
|
||||
continue
|
||||
case PASSWORD:
|
||||
ssn.passwordHandler(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||
// Check against valid SMTP commands
|
||||
if cmd == "" {
|
||||
@@ -219,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
break
|
||||
}
|
||||
// not an EOF
|
||||
// Not an EOF
|
||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
@@ -257,9 +285,10 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
s.remoteDomain = domain
|
||||
// features before SIZE per RFC
|
||||
// Features before SIZE per RFC
|
||||
s.send("250-" + readyBanner)
|
||||
s.send("250-8BITMIME")
|
||||
s.send("250-AUTH PLAIN LOGIN")
|
||||
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||
s.send("250-STARTTLS")
|
||||
}
|
||||
@@ -281,30 +310,71 @@ func parseHelloArgument(arg string) (string, error) {
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func (s *Session) loginHandler(line string) {
|
||||
// Content and length of username is ignored.
|
||||
s.send(fmt.Sprintf("334 %v", passwordChallenge))
|
||||
s.enterState(PASSWORD)
|
||||
}
|
||||
|
||||
func (s *Session) passwordHandler(line string) {
|
||||
// Content and length of password is ignored.
|
||||
s.send("235 Authentication successful")
|
||||
s.enterState(READY)
|
||||
}
|
||||
|
||||
// READY state -> waiting for MAIL
|
||||
// AUTH can change
|
||||
func (s *Session) readyHandler(cmd string, arg string) {
|
||||
if cmd == "STARTTLS" {
|
||||
if !s.Server.config.TLSEnabled {
|
||||
// invalid command since unconfigured
|
||||
// Invalid command since TLS unconfigured.
|
||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||
s.send("454 TLS unavailable on the server")
|
||||
return
|
||||
}
|
||||
if s.tlsState != nil {
|
||||
// tls state previously valid
|
||||
// TLS state previously valid.
|
||||
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||
s.send("454 A TLS session already agreed upon.")
|
||||
return
|
||||
}
|
||||
s.logger.Debug().Msg("Initiating TLS context.")
|
||||
|
||||
// Start TLS connection handshake.
|
||||
s.send("220 STARTTLS")
|
||||
// start tls connection handshake
|
||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||
s.conn = tlsConn
|
||||
s.text = textproto.NewConn(s.conn)
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
s.enterState(GREET)
|
||||
} else if cmd == "AUTH" {
|
||||
args := strings.SplitN(arg, " ", 3)
|
||||
authMethod := args[0]
|
||||
switch authMethod {
|
||||
case "PLAIN":
|
||||
{
|
||||
if len(args) != 2 {
|
||||
s.send("500 Bad auth arguments")
|
||||
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
|
||||
return
|
||||
}
|
||||
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
|
||||
s.send("235 2.7.0 Authentication successful")
|
||||
return
|
||||
}
|
||||
case "LOGIN":
|
||||
{
|
||||
s.send(fmt.Sprintf("334 %v", usernameChallenge))
|
||||
s.enterState(LOGIN)
|
||||
return
|
||||
}
|
||||
default:
|
||||
{
|
||||
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if cmd == "MAIL" {
|
||||
// Capture group 1: from address. 2: optional params.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
@@ -314,7 +384,8 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||
localpart, domain, err := policy.ParseEmailAddress(from)
|
||||
if from != "" && err != nil {
|
||||
s.send("501 Bad sender address syntax")
|
||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
@@ -346,10 +417,22 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.from = from
|
||||
s.logger.Info().Msgf("Mail from: %v", from)
|
||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||
s.enterState(MAIL)
|
||||
|
||||
// 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.logger.Info().Msgf("Mail from: %v", from)
|
||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||
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")
|
||||
@@ -518,40 +601,40 @@ func (s *Session) readLine() (line string, err error) {
|
||||
|
||||
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
l := len(line)
|
||||
|
||||
// Find length of command or entire line.
|
||||
hasArg := true
|
||||
l := strings.IndexByte(line, ' ')
|
||||
if l == -1 {
|
||||
hasArg = false
|
||||
l = len(line)
|
||||
}
|
||||
|
||||
switch {
|
||||
case l == 0:
|
||||
return "", "", true
|
||||
case l < 4:
|
||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||
return "", "", false
|
||||
case l == 4 || l == 8:
|
||||
return strings.ToUpper(line), "", true
|
||||
case l == 5:
|
||||
// Too long to be only command, too short to have args
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// If we made it here, command is long enough to have args
|
||||
if line[4] != ' ' {
|
||||
// There wasn't a space after the command?
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
if hasArg {
|
||||
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
|
||||
}
|
||||
|
||||
// I'm not sure if we should trim the args or not, but we will for now
|
||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||
return strings.ToUpper(line), "", true
|
||||
}
|
||||
|
||||
// parseArgs takes the arguments proceeding a command and files them
|
||||
// into a map[string]string after uppercasing each key. Sample arg
|
||||
// string:
|
||||
// " BODY=8BITMIME SIZE=1024"
|
||||
//
|
||||
// " BODY=8BITMIME SIZE=1024"
|
||||
//
|
||||
// The leading space is mandatory.
|
||||
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||
args = make(map[string]string)
|
||||
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||
re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
|
||||
pm := re.FindAllStringSubmatch(arg, -1)
|
||||
if pm == nil {
|
||||
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"log"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -24,14 +25,44 @@ type scriptStep struct {
|
||||
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) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
defer server.Drain() // Required to prevent test logging data race.
|
||||
|
||||
// Test out some mangled HELOs
|
||||
script := []scriptStep{
|
||||
tests := []scriptStep{
|
||||
{"HELO", 501},
|
||||
{"EHLO", 501},
|
||||
{"HELLO", 500},
|
||||
@@ -39,51 +70,24 @@ func TestGreetState(t *testing.T) {
|
||||
{"hello", 500},
|
||||
{"Outlook", 500},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Valid HELOs
|
||||
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
|
||||
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 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)
|
||||
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 commands in READY state
|
||||
func TestEmptyEnvelope(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
defer server.Drain()
|
||||
|
||||
// Test out some empty envelope without blanks
|
||||
script := []scriptStep{
|
||||
@@ -91,8 +95,6 @@ func TestEmptyEnvelope(t *testing.T) {
|
||||
{"MAIL FROM:<>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
@@ -102,21 +104,106 @@ func TestEmptyEnvelope(t *testing.T) {
|
||||
{"MAIL FROM: <>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestReadyState(t *testing.T) {
|
||||
// Test AUTH commands.
|
||||
func TestAuth(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
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{
|
||||
{"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},
|
||||
{"HELO", 503},
|
||||
{"DATA", 503},
|
||||
@@ -128,50 +215,27 @@ func TestReadyState(t *testing.T) {
|
||||
{"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)
|
||||
|
||||
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 out some valid MAIL commands
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
{"RSET", 250},
|
||||
{"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 {
|
||||
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
|
||||
func TestMailState(t *testing.T) {
|
||||
mds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(mds, extension.NewHost())
|
||||
defer server.Drain()
|
||||
|
||||
// Test out some mangled READY commands
|
||||
script := []scriptStep{
|
||||
@@ -203,6 +267,8 @@ func TestMailState(t *testing.T) {
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<john@gmail.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 {
|
||||
t.Error(err)
|
||||
@@ -270,23 +336,16 @@ func TestMailState(t *testing.T) {
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in DATA state
|
||||
func TestDataState(t *testing.T) {
|
||||
mds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(mds, extension.NewHost())
|
||||
defer server.Drain()
|
||||
|
||||
var script []scriptStep
|
||||
pipe := setupSMTPSession(server)
|
||||
pipe := setupSMTPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
@@ -301,6 +360,7 @@ func TestDataState(t *testing.T) {
|
||||
if err := playScriptAgainst(t, c, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Send a message
|
||||
body := `To: u1@gmail.com
|
||||
From: john@gmail.com
|
||||
@@ -314,9 +374,11 @@ Hi!
|
||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||
}
|
||||
_, _ = c.Cmd("QUIT")
|
||||
_, _, _ = c.ReadCodeLine(221)
|
||||
|
||||
// Test with no useful headers.
|
||||
pipe = setupSMTPSession(server)
|
||||
pipe = setupSMTPSession(t, server)
|
||||
c = textproto.NewConn(pipe)
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||
@@ -330,29 +392,25 @@ Hi!
|
||||
if err := playScriptAgainst(t, c, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Send a message
|
||||
body = `X-Useless-Header: true
|
||||
|
||||
Hi! Can you still deliver this?
|
||||
`
|
||||
Hi! Can you still deliver this?
|
||||
`
|
||||
dw = c.DotWriter()
|
||||
_, _ = io.WriteString(dw, body)
|
||||
_ = dw.Close()
|
||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_, _ = c.Cmd("QUIT")
|
||||
_, _, _ = c.ReadCodeLine(221)
|
||||
}
|
||||
|
||||
// playSession creates a new session, reads the greeting and then plays the script
|
||||
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
||||
pipe := setupSMTPSession(server)
|
||||
pipe := setupSMTPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
@@ -393,6 +451,93 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
|
||||
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
|
||||
type mockConn struct {
|
||||
net.Conn
|
||||
@@ -402,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) 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{
|
||||
MailboxNaming: config.FullNaming,
|
||||
SMTP: config.SMTP{
|
||||
@@ -415,28 +560,24 @@ func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown f
|
||||
Timeout: 5,
|
||||
},
|
||||
}
|
||||
// Capture log output.
|
||||
buf = new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
|
||||
// Create a server, don't start it.
|
||||
shutdownChan := make(chan bool)
|
||||
teardown = func() {
|
||||
close(shutdownChan)
|
||||
}
|
||||
addrPolicy := &policy.Addressing{Config: cfg}
|
||||
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
|
||||
|
||||
func setupSMTPSession(server *Server) net.Conn {
|
||||
// Pair of pipes to communicate.
|
||||
func setupSMTPSession(t *testing.T, server *Server) net.Conn {
|
||||
logger := zerolog.New(zerolog.NewTestWriter(t))
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
// Start the session.
|
||||
server.wg.Add(1)
|
||||
sessionNum++
|
||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
||||
go server.startSession(sessionNum, &mockConn{serverConn}, logger)
|
||||
|
||||
return clientConn
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
@@ -58,21 +59,22 @@ func init() {
|
||||
|
||||
// Server holds the configuration and state of our SMTP server.
|
||||
type Server struct {
|
||||
config config.SMTP // SMTP configuration.
|
||||
addrPolicy *policy.Addressing // Address policy.
|
||||
globalShutdown chan bool // Shuts down Inbucket.
|
||||
manager message.Manager // Used to deliver messages.
|
||||
listener net.Listener // Incoming network connections.
|
||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||
tlsConfig *tls.Config
|
||||
config config.SMTP // SMTP configuration.
|
||||
tlsConfig *tls.Config // TLS encryption configuration.
|
||||
addrPolicy *policy.Addressing // Address policy.
|
||||
manager message.Manager // Used to deliver messages.
|
||||
extHost *extension.Host // Extension event processor.
|
||||
listener net.Listener // Incoming network connections.
|
||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||
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(
|
||||
smtpConfig config.SMTP,
|
||||
globalShutdown chan bool,
|
||||
manager message.Manager,
|
||||
apolicy *policy.Addressing,
|
||||
extHost *extension.Host,
|
||||
) *Server {
|
||||
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||
tlsConfig := &tls.Config{}
|
||||
@@ -90,37 +92,44 @@ func NewServer(
|
||||
}
|
||||
|
||||
return &Server{
|
||||
config: smtpConfig,
|
||||
globalShutdown: globalShutdown,
|
||||
manager: manager,
|
||||
addrPolicy: apolicy,
|
||||
wg: new(sync.WaitGroup),
|
||||
tlsConfig: tlsConfig,
|
||||
config: smtpConfig,
|
||||
tlsConfig: tlsConfig,
|
||||
manager: manager,
|
||||
addrPolicy: apolicy,
|
||||
extHost: extHost,
|
||||
wg: new(sync.WaitGroup),
|
||||
notify: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||
if err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
// Listener go routine.
|
||||
|
||||
// Start listener go routine.
|
||||
go s.serve(ctx)
|
||||
readyFunc()
|
||||
|
||||
// Wait for shutdown.
|
||||
<-ctx.Done()
|
||||
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
|
||||
slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit.
|
||||
if err := s.listener.Close(); err != nil {
|
||||
slog.Error().Err(err).Msg("Failed to close SMTP listener")
|
||||
@@ -156,7 +165,8 @@ func (s *Server) serve(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
// Something went wrong.
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -164,23 +174,18 @@ func (s *Server) serve(ctx context.Context) {
|
||||
tempDelay = 0
|
||||
expConnectsTotal.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
|
||||
func (s *Server) Drain() {
|
||||
// Wait for sessions to close.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ func spaTemplateHandler(tmpl *template.Template, 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).
|
||||
|
||||
@@ -31,10 +31,9 @@ var (
|
||||
// incoming requests to the correct handler function
|
||||
Router = mux.NewRouter()
|
||||
|
||||
rootConfig *config.Root
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
globalShutdown chan bool
|
||||
rootConfig *config.Root
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
||||
@@ -45,15 +44,19 @@ func init() {
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method.
|
||||
func Initialize(
|
||||
// Server defines an instance of the Web server.
|
||||
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,
|
||||
shutdownChan chan bool,
|
||||
mm message.Manager,
|
||||
mh *msghub.Hub) {
|
||||
mh *msghub.Hub) *Server {
|
||||
|
||||
rootConfig = conf
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers.
|
||||
msgHub = mh
|
||||
@@ -118,10 +121,16 @@ func Initialize(
|
||||
http.StatusNotFound, "No route matches URI path")
|
||||
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||
|
||||
s := &Server{
|
||||
notify: make(chan error, 1),
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
||||
server = &http.Server{
|
||||
Addr: rootConfig.Web.Addr,
|
||||
Handler: requestLoggingWrapper(Router),
|
||||
@@ -137,12 +146,14 @@ func Start(ctx context.Context) {
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||
Msg("HTTP failed to start TCP4 listener")
|
||||
emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go serve(ctx)
|
||||
// Start listener go routine
|
||||
go s.serve(ctx)
|
||||
readyFunc()
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
@@ -176,7 +187,7 @@ func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||
}
|
||||
|
||||
// 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
|
||||
err := server.Serve(listener)
|
||||
|
||||
@@ -186,16 +197,13 @@ func serve(ctx context.Context) {
|
||||
default:
|
||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||
Msg("HTTP server failed")
|
||||
emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
default:
|
||||
close(globalShutdown)
|
||||
}
|
||||
// Notify allows the running Web server to be monitored for a fatal error.
|
||||
func (s *Server) Notify() <-chan error {
|
||||
return s.notify
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"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/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -45,10 +47,11 @@ type Store struct {
|
||||
mailPath string
|
||||
messageCap int
|
||||
bufReaderPool sync.Pool
|
||||
extHost *extension.Host
|
||||
}
|
||||
|
||||
// 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"]
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("'path' parameter not specified")
|
||||
@@ -70,6 +73,7 @@ func New(cfg config.Storage) (storage.Store, error) {
|
||||
return bufio.NewReader(nil)
|
||||
},
|
||||
},
|
||||
extHost: extHost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -183,6 +187,17 @@ func (fs *Store) PurgeMessages(mailbox string) error {
|
||||
mb := fs.mbox(mailbox)
|
||||
mb.Lock()
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"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/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
@@ -22,18 +24,19 @@ import (
|
||||
|
||||
// TestSuite runs storage package test suite on file store.
|
||||
func TestSuite(t *testing.T) {
|
||||
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
||||
ds, _ := setupDataStore(conf)
|
||||
destroy := func() {
|
||||
teardownDataStore(ds)
|
||||
}
|
||||
return ds, destroy, nil
|
||||
})
|
||||
test.StoreSuite(t,
|
||||
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
|
||||
ds, _ := setupDataStore(conf, extHost)
|
||||
destroy := func() {
|
||||
teardownDataStore(ds)
|
||||
}
|
||||
return ds, destroy, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Test directory structure created by filestore
|
||||
func TestFSDirStructure(t *testing.T) {
|
||||
ds, logbuf := setupDataStore(config.Storage{})
|
||||
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||
defer teardownDataStore(ds)
|
||||
root := ds.path
|
||||
|
||||
@@ -111,7 +114,7 @@ func TestFSDirStructure(t *testing.T) {
|
||||
|
||||
// Test missing files
|
||||
func TestFSMissing(t *testing.T) {
|
||||
ds, logbuf := setupDataStore(config.Storage{})
|
||||
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
@@ -146,7 +149,7 @@ func TestFSMissing(t *testing.T) {
|
||||
|
||||
// Test Get the latest message
|
||||
func TestGetLatestMessage(t *testing.T) {
|
||||
ds, logbuf := setupDataStore(config.Storage{})
|
||||
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||
@@ -188,22 +191,25 @@ func TestGetLatestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Capture log output.
|
||||
buf := new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
|
||||
if cfg.Params == nil {
|
||||
cfg.Params = make(map[string]string)
|
||||
}
|
||||
cfg.Params["path"] = path
|
||||
s, err := New(cfg)
|
||||
s, err := New(cfg, extHost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return s.(*Store), buf
|
||||
}
|
||||
|
||||
@@ -211,7 +217,7 @@ func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
|
||||
// the size of the generated message.
|
||||
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
|
||||
// Build message for delivery
|
||||
meta := message.Metadata{
|
||||
meta := event.MessageMetadata{
|
||||
Mailbox: mbName,
|
||||
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
|
||||
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -72,6 +73,10 @@ func (mb *mbox) removeMessage(id string) error {
|
||||
msg = m
|
||||
// Slice around message we are deleting
|
||||
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
|
||||
|
||||
// Emit deleted event.
|
||||
mb.store.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(msg))
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,7 @@ type Store struct {
|
||||
cap int // Per-mailbox message cap.
|
||||
incoming chan *msgDone // New messages for size enforcer.
|
||||
remove chan *msgDone // Remove deleted messages from size enforcer.
|
||||
extHost *extension.Host
|
||||
}
|
||||
|
||||
type mbox struct {
|
||||
@@ -31,10 +34,11 @@ type mbox struct {
|
||||
var _ storage.Store = &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{
|
||||
boxes: make(map[string]*mbox),
|
||||
cap: cfg.MailboxMsgCap,
|
||||
boxes: make(map[string]*mbox),
|
||||
cap: cfg.MailboxMsgCap,
|
||||
extHost: extHost,
|
||||
}
|
||||
if str, ok := cfg.Params["maxkb"]; ok {
|
||||
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.
|
||||
func (s *Store) PurgeMessages(mailbox string) error {
|
||||
// Grab lock, copy messages, clear, and drop lock.
|
||||
var messages map[string]*Message
|
||||
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||
messages = mb.messages
|
||||
mb.messages = make(map[string]*Message)
|
||||
})
|
||||
if len(messages) > 0 && s.remove != nil {
|
||||
|
||||
// Process size/quota.
|
||||
if s.remove != nil {
|
||||
for _, m := range messages {
|
||||
s.enforcerRemove(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit delete events.
|
||||
for _, m := range messages {
|
||||
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +176,11 @@ func (s *Store) removeMessage(mailbox, id string) *Message {
|
||||
delete(mb.messages, id)
|
||||
}
|
||||
})
|
||||
|
||||
if m != nil {
|
||||
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -6,27 +6,32 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
// TestSuite runs storage package test suite on file store.
|
||||
func TestSuite(t *testing.T) {
|
||||
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
||||
s, _ := New(conf)
|
||||
destroy := func() {}
|
||||
return s, destroy, nil
|
||||
})
|
||||
test.StoreSuite(t,
|
||||
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
|
||||
s, _ := New(conf, extHost)
|
||||
destroy := func() {}
|
||||
return s, destroy, nil
|
||||
})
|
||||
}
|
||||
|
||||
// TestMessageList verifies the operation of the global message list: mem.Store.messages.
|
||||
func TestMaxSize(t *testing.T) {
|
||||
extHost := extension.NewHost()
|
||||
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"}
|
||||
|
||||
// Ensure capacity so we do not block population.
|
||||
n := 10
|
||||
// total := 50
|
||||
sizeChan := make(chan int64, len(boxes))
|
||||
|
||||
// Populate mailboxes concurrently.
|
||||
for _, mailbox := range boxes {
|
||||
go func(mailbox string) {
|
||||
@@ -38,11 +43,13 @@ func TestMaxSize(t *testing.T) {
|
||||
sizeChan <- size
|
||||
}(mailbox)
|
||||
}
|
||||
|
||||
// Wait for sizes.
|
||||
sentBytesTotal := int64(0)
|
||||
for range boxes {
|
||||
sentBytesTotal += <-sizeChan
|
||||
}
|
||||
|
||||
// Calculate actual size.
|
||||
gotSize := int64(0)
|
||||
s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
@@ -51,6 +58,7 @@ func TestMaxSize(t *testing.T) {
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Verify state. Messages are ~75 bytes each.
|
||||
if 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 {
|
||||
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
|
||||
}
|
||||
|
||||
// Purge all messages concurrently, testing for deadlocks.
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(len(boxes))
|
||||
@@ -65,7 +74,7 @@ func TestMaxSize(t *testing.T) {
|
||||
go func(mailbox string) {
|
||||
err := s.PurgeMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
panic(err) // Cannot call t.Fatal from non-test goroutine.
|
||||
}
|
||||
wg.Done()
|
||||
}(mailbox)
|
||||
|
||||
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"expvar"
|
||||
"time"
|
||||
|
||||
@@ -50,7 +51,6 @@ func init() {
|
||||
|
||||
// RetentionScanner looks for messages older than the configured retention period and deletes them.
|
||||
type RetentionScanner struct {
|
||||
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
||||
retentionShutdown chan bool // Closed after the scanner has shut down
|
||||
ds Store
|
||||
retentionPeriod time.Duration
|
||||
@@ -61,10 +61,8 @@ type RetentionScanner struct {
|
||||
func NewRetentionScanner(
|
||||
cfg config.Storage,
|
||||
ds Store,
|
||||
shutdownChannel chan bool,
|
||||
) *RetentionScanner {
|
||||
rs := &RetentionScanner{
|
||||
globalShutdown: shutdownChannel,
|
||||
retentionShutdown: make(chan bool),
|
||||
ds: ds,
|
||||
retentionPeriod: cfg.RetentionPeriod,
|
||||
@@ -76,20 +74,16 @@ func NewRetentionScanner(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled")
|
||||
slog.Info().Str("phase", "startup").Msg("Retention scanner disabled")
|
||||
close(rs.retentionShutdown)
|
||||
return
|
||||
}
|
||||
log.Info().Str("phase", "startup").Str("module", "storage").
|
||||
Msgf("Retention configured for %v", rs.retentionPeriod)
|
||||
go rs.run()
|
||||
}
|
||||
slog.Info().Str("phase", "startup").Msgf("Retention configured for %v", rs.retentionPeriod)
|
||||
|
||||
// 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()
|
||||
retentionLoop:
|
||||
for {
|
||||
@@ -99,19 +93,19 @@ retentionLoop:
|
||||
dur := time.Minute - since
|
||||
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
case <-ctx.Done():
|
||||
break retentionLoop
|
||||
case <-time.After(dur):
|
||||
}
|
||||
}
|
||||
// Kickoff scan
|
||||
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")
|
||||
}
|
||||
// Check for global shutdown
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
case <-ctx.Done():
|
||||
break retentionLoop
|
||||
default:
|
||||
}
|
||||
@@ -121,13 +115,14 @@ retentionLoop:
|
||||
}
|
||||
|
||||
// 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.Debug().Msg("Starting retention scan")
|
||||
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
||||
|
||||
// Loop over all mailboxes.
|
||||
retained := 0
|
||||
storeSize := int64(0)
|
||||
// Loop over all mailboxes.
|
||||
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
|
||||
for _, msg := range messages {
|
||||
if msg.Date().Before(cutoff) {
|
||||
@@ -145,7 +140,7 @@ func (rs *RetentionScanner) DoScan() error {
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
case <-ctx.Done():
|
||||
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
|
||||
return false
|
||||
case <-time.After(rs.retentionSleep):
|
||||
@@ -156,10 +151,12 @@ func (rs *RetentionScanner) DoScan() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
expRetainedSize.Set(storeSize)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
func TestDoRetentionScan(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
|
||||
// Mockup some different aged messages (num is in hours)
|
||||
new1 := stubMessage("mb1", 0)
|
||||
new2 := stubMessage("mb2", 1)
|
||||
@@ -26,22 +29,26 @@ func TestDoRetentionScan(t *testing.T) {
|
||||
ds.AddMessage(old3)
|
||||
ds.AddMessage(new2)
|
||||
ds.AddMessage(new3)
|
||||
|
||||
// Test 4 hour retention
|
||||
cfg := config.Storage{
|
||||
RetentionPeriod: 239 * time.Minute,
|
||||
RetentionSleep: 0,
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
rs := storage.NewRetentionScanner(cfg, ds, shutdownChan)
|
||||
if err := rs.DoScan(); err != nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rs := storage.NewRetentionScanner(cfg, ds)
|
||||
if err := rs.DoScan(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Delete should not have been called on new messages
|
||||
for _, m := range []storage.Message{new1, new2, new3} {
|
||||
if ds.MessageDeleted(m) {
|
||||
t.Errorf("Expected %v to be present, was deleted", m.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete should have been called once on old messages
|
||||
for _, m := range []storage.Message{old1, old2, old3} {
|
||||
if !ds.MessageDeleted(m) {
|
||||
@@ -53,7 +60,7 @@ func TestDoRetentionScan(t *testing.T) {
|
||||
// stubMessage creates a message stub of a specific age
|
||||
func stubMessage(mailbox string, ageHours int) storage.Message {
|
||||
return &message.Delivery{
|
||||
Meta: message.Metadata{
|
||||
Meta: event.MessageMetadata{
|
||||
Mailbox: mailbox,
|
||||
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
|
||||
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -19,7 +20,7 @@ var (
|
||||
ErrNotWritable = errors.New("Message not writable")
|
||||
|
||||
// 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.
|
||||
@@ -48,9 +49,9 @@ type Message interface {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return cf(c)
|
||||
return cf(c, extHost)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
@@ -32,6 +33,8 @@ const (
|
||||
smtpHost = "127.0.0.1:2500"
|
||||
)
|
||||
|
||||
// TODO: Add suites for domain and full addressing modes.
|
||||
|
||||
func TestSuite(t *testing.T) {
|
||||
stopServer, err := startServer()
|
||||
if err != nil {
|
||||
@@ -46,6 +49,8 @@ func TestSuite(t *testing.T) {
|
||||
{"basic", testBasic},
|
||||
{"fullname", testFullname},
|
||||
{"encodedHeader", testEncodedHeader},
|
||||
{"ipv4Recipient", testIPv4Recipient},
|
||||
{"ipv6Recipient", testIPv6Recipient},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, tc.test)
|
||||
@@ -139,6 +144,64 @@ func testEncodedHeader(t *testing.T) {
|
||||
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 {
|
||||
b := &bytes.Buffer{}
|
||||
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
|
||||
@@ -152,40 +215,46 @@ func formatMessage(m *client.Message) []byte {
|
||||
}
|
||||
|
||||
func startServer() (func(), error) {
|
||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
||||
// TODO Move integration setup into lifecycle.
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||
|
||||
extHost := extension.NewHost()
|
||||
|
||||
// Storage setup.
|
||||
storage.Constructors["memory"] = mem.New
|
||||
os.Clearenv()
|
||||
conf, err := config.Process()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
shutdownChan := make(chan bool)
|
||||
store, err := storage.FromConfig(conf.Storage)
|
||||
svcCtx, svcCancel := context.WithCancel(context.Background())
|
||||
store, err := storage.FromConfig(conf.Storage, extHost)
|
||||
if err != nil {
|
||||
rootCancel()
|
||||
svcCancel()
|
||||
return nil, err
|
||||
}
|
||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||
|
||||
// TODO Test should not pass with unstarted msghub.
|
||||
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.
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
webServer := web.NewServer(conf, mmanager, msgHub)
|
||||
go webServer.Start(svcCtx, func() {})
|
||||
|
||||
// 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)
|
||||
|
||||
return func() {
|
||||
// Shut everything down.
|
||||
close(shutdownChan)
|
||||
rootCancel()
|
||||
svcCancel()
|
||||
smtpServer.Drain()
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"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.
|
||||
func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) {
|
||||
func (m *ManagerStub) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
|
||||
if mailbox == "messageserr" {
|
||||
return nil, errors.New("internal error")
|
||||
}
|
||||
messages := m.mailboxes[mailbox]
|
||||
metas := make([]*message.Metadata, len(messages))
|
||||
metas := make([]*event.MessageMetadata, len(messages))
|
||||
for i, msg := range messages {
|
||||
metas[i] = &msg.Metadata
|
||||
metas[i] = &msg.MessageMetadata
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
|
||||
}
|
||||
for _, msg := range m.mailboxes[mailbox] {
|
||||
if msg.ID == id {
|
||||
msg.Metadata.Seen = true
|
||||
msg.MessageMetadata.Seen = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ import (
|
||||
"time"
|
||||
|
||||
"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/storage"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func StoreSuite(t *testing.T, factory StoreFactory) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
test func(*testing.T, storage.Store)
|
||||
test func(*testing.T, storage.Store, *extension.Host)
|
||||
conf config.Storage
|
||||
}{
|
||||
{"metadata", testMetadata, config.Storage{}},
|
||||
@@ -39,18 +44,19 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.test(t, store)
|
||||
tc.test(t, store, extHost)
|
||||
destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
|
||||
to := []*mail.Address{
|
||||
@@ -61,7 +67,7 @@ func testMetadata(t *testing.T, store storage.Store) {
|
||||
subject := "fantastic test subject line"
|
||||
content := "doesn't matter"
|
||||
delivery := &message.Delivery{
|
||||
Meta: message.Metadata{
|
||||
Meta: event.MessageMetadata{
|
||||
// ID and Size will be determined by the Store.
|
||||
Mailbox: mailbox,
|
||||
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.
|
||||
func testContent(t *testing.T, store storage.Store) {
|
||||
func testContent(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
content := make([]byte, 5000)
|
||||
for i := 0; i < len(content); i++ {
|
||||
content[i] = byte(i % 256)
|
||||
@@ -130,7 +136,7 @@ func testContent(t *testing.T, store storage.Store) {
|
||||
date := time.Now()
|
||||
subject := "fantastic test subject line"
|
||||
delivery := &message.Delivery{
|
||||
Meta: message.Metadata{
|
||||
Meta: event.MessageMetadata{
|
||||
// ID and Size will be determined by the Store.
|
||||
Mailbox: mailbox,
|
||||
From: from,
|
||||
@@ -168,14 +174,13 @@ func testContent(t *testing.T, store storage.Store) {
|
||||
}
|
||||
if errors > 5 {
|
||||
t.Fatalf("Too many content errors, aborting test.")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
|
||||
// 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"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
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
|
||||
// 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"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
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.
|
||||
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())
|
||||
GetAndCountMessages(t, store, "fred", 0)
|
||||
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
||||
}
|
||||
|
||||
// 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"
|
||||
subjects := []string{"a", "br", "much longer than the others"}
|
||||
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.
|
||||
func testSeen(t *testing.T, store storage.Store) {
|
||||
func testSeen(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
mailbox := "lisa"
|
||||
id1, _ := DeliverToStore(t, store, mailbox, "whatever", 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.
|
||||
func testDelete(t *testing.T, store storage.Store) {
|
||||
func testDelete(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
}
|
||||
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
|
||||
// Subscribe to events.
|
||||
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
|
||||
|
||||
// Delete a couple messages.
|
||||
err := store.RemoveMessage(mailbox, msgs[1].ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = store.RemoveMessage(mailbox, msgs[3].ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
deleteIDs := []string{msgs[1].ID(), msgs[3].ID()}
|
||||
for _, id := range deleteIDs {
|
||||
err := store.RemoveMessage(mailbox, id)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Confirm deletion.
|
||||
subjects = []string{"alpha", "charlie", "echo"}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
|
||||
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.
|
||||
func testPurge(t *testing.T, store storage.Store) {
|
||||
func testPurge(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
|
||||
// Subscribe to events.
|
||||
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
|
||||
|
||||
// Populate mailbox.
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
}
|
||||
GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
|
||||
// Purge and verify.
|
||||
err := store.PurgeMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
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.
|
||||
func testMsgCap(t *testing.T, store storage.Store) {
|
||||
func testMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
mbCap := 10
|
||||
mailbox := "captain"
|
||||
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.
|
||||
func testNoMsgCap(t *testing.T, store storage.Store) {
|
||||
func testNoMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
|
||||
mailbox := "captain"
|
||||
for i := 0; i < 20; 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
|
||||
// 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"}
|
||||
for _, name := range boxes {
|
||||
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
|
||||
@@ -409,7 +444,7 @@ func DeliverToStore(
|
||||
date time.Time,
|
||||
) (string, int64) {
|
||||
t.Helper()
|
||||
meta := message.Metadata{
|
||||
meta := event.MessageMetadata{
|
||||
Mailbox: mailbox,
|
||||
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
|
||||
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
|
||||
|
||||
12
pkg/test/testdata/no-to-ipv4.golden
vendored
Normal file
12
pkg/test/testdata/no-to-ipv4.golden
vendored
Normal file
@@ -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:
|
||||
|
||||
12
pkg/test/testdata/no-to-ipv6.golden
vendored
Normal file
12
pkg/test/testdata/no-to-ipv6.golden
vendored
Normal file
@@ -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:
|
||||
|
||||
4
pkg/test/testdata/no-to.txt
vendored
Normal file
4
pkg/test/testdata/no-to.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
From: fromuser@inbucket.org
|
||||
Subject: basic subject
|
||||
|
||||
No-To message.
|
||||
34
shell.nix
34
shell.nix
@@ -1,18 +1,38 @@
|
||||
with import <nixpkgs> {};
|
||||
stdenv.mkDerivation rec {
|
||||
name = "env";
|
||||
env = buildEnv { name = name; paths = buildInputs; };
|
||||
buildInputs = [
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
let
|
||||
scripts = {
|
||||
# Quick test script.
|
||||
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
|
||||
delve
|
||||
elmPackages.elm
|
||||
elmPackages.elm-analyse
|
||||
elmPackages.elm-format
|
||||
elmPackages.elm-json
|
||||
elmPackages.elm-language-server
|
||||
elmPackages.elm-test
|
||||
go
|
||||
go_1_18
|
||||
golint
|
||||
nodejs-10_x
|
||||
gopls
|
||||
nodejs-16_x
|
||||
nodePackages.yarn
|
||||
rpm
|
||||
swaks
|
||||
|
||||
scripts.qt
|
||||
];
|
||||
|
||||
# Prevents launch errors with delve debugger.
|
||||
hardeningDisable = [ "fortify" ];
|
||||
}
|
||||
|
||||
4
ui/.parcelrc
Normal file
4
ui/.parcelrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"namers": [ "parcel-namer-rewrite" ]
|
||||
}
|
||||
12
ui/.proxyrc.json
Normal file
12
ui/.proxyrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:9000",
|
||||
"ws": true
|
||||
},
|
||||
"/debug": {
|
||||
"target": "http://localhost:9000"
|
||||
},
|
||||
"/serve": {
|
||||
"target": "http://localhost:9000"
|
||||
}
|
||||
}
|
||||
14
ui/README.md
14
ui/README.md
@@ -11,9 +11,8 @@ One time setup (assuming [Node.js] is already installed):
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
npm i elm -g
|
||||
npm i
|
||||
npm run build
|
||||
yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
||||
@@ -30,15 +29,16 @@ Inbucket will start, with HTTP listening on port 9000. You may verify the web
|
||||
UI is functional if this is your first time building Inbucket, but your dev/test
|
||||
cycle should favor the development server below.
|
||||
|
||||
### Terminal 2: webpack development server
|
||||
### Terminal 2: parcel development server
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
npm run dev
|
||||
yarn start
|
||||
```
|
||||
|
||||
npm will start a development HTTP server listening on port 3000. You should use
|
||||
this server for UI development, as it features hot reload and the Elm debugger.
|
||||
yarn will start a development HTTP server listening on port 1234. You should
|
||||
use this server for UI development, as it features hot reload and the Elm
|
||||
debugger.
|
||||
|
||||
[Elm]: https://elm-lang.org
|
||||
[Node.js]: https://nodejs.org
|
||||
|
||||
8006
ui/package-lock.json
generated
8006
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,29 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist elm-stuff",
|
||||
"build": "webpack --mode production",
|
||||
"watch": "webpack --mode development --watch",
|
||||
"dev": "webpack-dev-server --mode development --port 3000 --hot --watch",
|
||||
"errors": "webpack --mode development --display-error-details"
|
||||
"build": "parcel build --public-url ./",
|
||||
"start": "parcel --hmr-port 1235 src/index-dev.html",
|
||||
"clean": "rm -rf .parcel-cache dist elm-stuff"
|
||||
},
|
||||
"dependencies": {
|
||||
"opensans-npm-webfont": "^1.0.0"
|
||||
"source": "src/index.html",
|
||||
"parcel-namer-rewrite": {
|
||||
"rules": {
|
||||
"(.*)\\.(css|js|json|eot|png|svg|ttf|webmanifest|woff|woff2)": "static/$1{.hash}.$2"
|
||||
}
|
||||
},
|
||||
"browserslist": "defaults",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-env": "^7.11.5",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"@webcomponents/webcomponentsjs": "^2.4.4",
|
||||
"babel-loader": "^8.1.0",
|
||||
"css-loader": "^4.3.0",
|
||||
"elm-hot-webpack-loader": "^1.1.7",
|
||||
"elm-webpack-loader": "^7.0.1",
|
||||
"file-loader": "^6.1.0",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"node-elm-compiler": "^5.0.5",
|
||||
"style-loader": "^1.2.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||
"@parcel/packager-raw-url": "2.4.1",
|
||||
"@parcel/transformer-elm": "^2.2.1",
|
||||
"@parcel/transformer-webmanifest": "2.4.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.5.0",
|
||||
"opensans-npm-webfont": "^1.0.0",
|
||||
"parcel": "^2.4.1",
|
||||
"parcel-namer-rewrite": "^2.0.0-rc.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"elm": "^0.19.1-5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ markMessageSeen session msg mailboxName id =
|
||||
|
||||
monitorUri : Session -> String
|
||||
monitorUri session =
|
||||
apiV1Url session [ "monitor", "messages" ]
|
||||
apiV2Url session [ "monitor", "messages" ]
|
||||
|
||||
|
||||
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
|
||||
@@ -135,14 +135,24 @@ 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).
|
||||
-}
|
||||
apiV1Url : Session -> List String -> String
|
||||
apiV1Url session elements =
|
||||
apiUrl : String -> Session -> List String -> String
|
||||
apiUrl version session elements =
|
||||
Url.Builder.absolute
|
||||
(List.concat
|
||||
[ splitBasePath session.config.basePath
|
||||
, [ "api", "v1" ]
|
||||
, [ "api", version ]
|
||||
, elements
|
||||
]
|
||||
)
|
||||
|
||||
44
ui/src/Data/MonitorEvent.elm
Normal file
44
ui/src/Data/MonitorEvent.elm
Normal file
@@ -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
|
||||
@@ -1,7 +1,8 @@
|
||||
module Page.Monitor exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Api
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.MessageHeader exposing (MessageHeader)
|
||||
import Data.MonitorEvent as MonitorEvent
|
||||
import Data.Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import Effect exposing (Effect)
|
||||
@@ -68,11 +69,23 @@ update msg model =
|
||||
( { model | connected = False }, Effect.none )
|
||||
|
||||
MessageReceived value ->
|
||||
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
|
||||
Ok header ->
|
||||
( { model | messages = header :: List.take 500 model.messages }
|
||||
, Effect.none
|
||||
)
|
||||
case D.decodeValue (MonitorEvent.decoder |> D.at [ "detail" ]) value of
|
||||
Ok event ->
|
||||
case event of
|
||||
MonitorEvent.MessageDeleted deleted ->
|
||||
( { model
|
||||
| messages =
|
||||
List.filter
|
||||
(\x -> x.mailbox /= deleted.mailbox || x.id /= deleted.id)
|
||||
model.messages
|
||||
}
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
MonitorEvent.MessageStored header ->
|
||||
( { model | messages = header :: List.take 500 model.messages }
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
Err err ->
|
||||
let
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,15 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- This index file will be served by the webpack development server. -->
|
||||
<!-- This index file will be served by the development server. -->
|
||||
<base href="/">
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<link rel="stylesheet" href="./main.css">
|
||||
<link rel="stylesheet" href="./navbar.css">
|
||||
<link rel="stylesheet" href="./mailbox.css">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<title>Inbucket</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -17,5 +22,6 @@
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,9 +6,14 @@
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<link rel="stylesheet" href="./main.css">
|
||||
<link rel="stylesheet" href="./navbar.css">
|
||||
<link rel="stylesheet" href="./mailbox.css">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<title>Inbucket</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,5 +21,6 @@
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,3 @@
|
||||
import './main.css'
|
||||
import './mailbox.css'
|
||||
import './navbar.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.css'
|
||||
import '@webcomponents/webcomponentsjs/webcomponents-bundle'
|
||||
import 'opensans-npm-webfont'
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
.message-list {
|
||||
display: block;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-list-controls {
|
||||
|
||||
119
ui/src/main.css
119
ui/src/main.css
@@ -11,6 +11,68 @@
|
||||
--selected-color: #eee;
|
||||
--focused-color: #fff;
|
||||
--focused-bg-color: #337ab7;
|
||||
|
||||
--input-bg: white;
|
||||
--input-bg-active: white;
|
||||
|
||||
--btn-default-bg-color: #337ab7;
|
||||
--btn-default-bg-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
--btn-default-color: #ffffff;
|
||||
--btn-danger-bg-color: #d9534f;
|
||||
--btn-danger-bg-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
|
||||
--btn-light-bg-color: #eee;
|
||||
--btn-light-bg-image: linear-gradient(to bottom, #f0f0f0 0, #e0e0e0 100%);
|
||||
|
||||
--monitor-header-bg: #e8e8e8;
|
||||
|
||||
--well-bg-color: #f5f5f5;
|
||||
--well-bg-image: linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);
|
||||
|
||||
--well-warn-bg-color: #fff8cf;
|
||||
--well-warn-bg-image: linear-gradient(to bottom, #fff899 0, #fff8cf 100%);
|
||||
--well-warn-color: inherit;
|
||||
|
||||
--well-error-bg-color: #f58080;
|
||||
--well-error-bg-image: linear-gradient(to bottom, #e86060 0, #f58080 100%);
|
||||
--well-error-color: inherit;
|
||||
|
||||
--well-border: #e8e8e8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: #202124;
|
||||
--primary-color: #bdc1c6;
|
||||
--high-color: #8ab4f8;
|
||||
--border-color: #5f6368;
|
||||
--selected-color: #303134;
|
||||
|
||||
--input-bg: var(--bg-color);
|
||||
--input-bg-active: rgb(48, 49, 52);
|
||||
|
||||
--btn-default-bg-color: #303134;
|
||||
--btn-default-bg-image: none;
|
||||
--btn-default-color: #e8eaed;
|
||||
/*--btn-danger-bg-color: #d9534f;*/
|
||||
--btn-danger-bg-image: none;
|
||||
/*--btn-light-bg-color: #eee;*/
|
||||
--btn-light-bg-image: none;
|
||||
|
||||
--monitor-header-bg: var(--selected-color);
|
||||
|
||||
--well-bg-color: var(--low-color);
|
||||
--well-bg-image: none;
|
||||
|
||||
--well-warn-bg-color: #c3c099;
|
||||
--well-warn-bg-image: none;
|
||||
--well-warn-color: var(--bg-color);
|
||||
|
||||
--well-error-bg-color: #e86060;
|
||||
--well-error-bg-image: none;
|
||||
--well-error-color: var(--bg-color);
|
||||
|
||||
--well-border: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
@@ -39,7 +101,7 @@ time, mark, audio, video {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #337ab7;
|
||||
color: var(--high-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -67,8 +129,8 @@ h1, h2, h3, h4, h5, h6, p {
|
||||
/** SHARED */
|
||||
|
||||
a.button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
background-color: var(--btn-default-bg-color);
|
||||
background-image: var(--btn-default-bg-image);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
@@ -82,11 +144,9 @@ a.button {
|
||||
}
|
||||
|
||||
.well {
|
||||
--light: #f5f5f5;
|
||||
--dark: #e8e8e8;
|
||||
background-color: var(--light);
|
||||
background-image: linear-gradient(to bottom, var(--dark) 0, var(--light) 100%);
|
||||
border: 1px solid var(--dark);
|
||||
background-color: var(--well-bg-color);
|
||||
background-image: var(--well-bg-image);
|
||||
border: 1px solid var(--well-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
padding: 6px 10px;
|
||||
@@ -98,8 +158,9 @@ a.button {
|
||||
}
|
||||
|
||||
.well-error {
|
||||
--light: #f58080;
|
||||
--dark: #e86060;
|
||||
background-color: var(--well-error-bg-color);
|
||||
background-image: var(--well-error-bg-image);
|
||||
color: var(--well-error-color);
|
||||
}
|
||||
|
||||
.well-error a {
|
||||
@@ -108,8 +169,22 @@ a.button {
|
||||
}
|
||||
|
||||
.well-warn {
|
||||
--light: #fff8cf;
|
||||
--dark: #fff899;
|
||||
background-color: var(--well-warn-bg-color);
|
||||
background-image: var(--well-warn-bg-image);
|
||||
color: var(--well-warn-color);
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
input:focus-visible, input:hover {
|
||||
outline: none;
|
||||
border: 1px solid var(--input-bg-active);
|
||||
background-color: var(--input-bg-active);
|
||||
}
|
||||
}
|
||||
|
||||
/** APP */
|
||||
@@ -237,11 +312,11 @@ h3 {
|
||||
}
|
||||
|
||||
.button-bar button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
background-color: var(--btn-default-bg-color);
|
||||
background-image: var(--btn-default-bg-image);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--btn-default-color);
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
@@ -254,18 +329,22 @@ h3 {
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.button-bar button:hover {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.button-bar *:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.button-bar button.danger {
|
||||
background-color: #d9534f;
|
||||
background-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
|
||||
background-color: var(--btn-danger-bg-color);
|
||||
background-image: var(--btn-danger-bg-image);
|
||||
}
|
||||
|
||||
.button-bar button.light {
|
||||
background-color: #eee;
|
||||
background-image: linear-gradient(to bottom, #f0f0f0 0, #e0e0e0 100%);
|
||||
background-color: var(--btn-light-bg-color);
|
||||
background-image: var(--btn-light-bg-image);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@@ -285,7 +364,7 @@ h3 {
|
||||
}
|
||||
|
||||
.metric-panel h2 {
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0, #e8e8e8 100%);
|
||||
background-color: var(--monitor-header-bg);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
|
||||
@@ -2,16 +2,34 @@
|
||||
|
||||
:root {
|
||||
--navbar-color: #9d9d9d;
|
||||
--navbar-color-active: var(--navbar-color);
|
||||
--navbar-bg: #222;
|
||||
--navbar-bg-active: #080808;
|
||||
--navbar-bg-border-active: none;
|
||||
--navbar-image: linear-gradient(to bottom, #3c3c3c 0, #222 100%);
|
||||
--navbar-height: 50px;
|
||||
--navbar-border-bottom: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--navbar-color: #969ba1;
|
||||
--navbar-color-active: #8ab4f8;
|
||||
--navbar-bg: var(--bg-color);
|
||||
--navbar-bg-active: none;
|
||||
--navbar-bg-border-active: 3px solid var(--navbar-color-active);
|
||||
--navbar-image: none;
|
||||
--navbar-border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.navbar {
|
||||
background-color: var(--navbar-bg);
|
||||
background-image: var(--navbar-image);
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
min-height: var(--navbar-height);
|
||||
border-bottom: var(--navbar-border-bottom);
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
@@ -66,7 +84,9 @@
|
||||
}
|
||||
|
||||
li.navbar-active > *:first-child {
|
||||
background-color: #080808;
|
||||
background-color: var(--navbar-bg-active);
|
||||
color: var(--navbar-color-active);
|
||||
border-bottom: var(--navbar-bg-border-active);
|
||||
}
|
||||
|
||||
li.navbar-active a,
|
||||
@@ -92,7 +112,6 @@ li.navbar-active span,
|
||||
}
|
||||
|
||||
.navbar-mailbox input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
width: 250px;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
const webpack = require('webpack')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const production = argv.mode === 'production'
|
||||
const config = {
|
||||
output: {
|
||||
filename: 'static/[name].[hash:8].js',
|
||||
publicPath: '',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: [/elm-stuff/, /node_modules/],
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.elm$/,
|
||||
exclude: [/elm-stuff/, /node_modules/],
|
||||
use: [
|
||||
{ loader: 'elm-hot-webpack-loader' },
|
||||
{
|
||||
loader: 'elm-webpack-loader',
|
||||
options: {
|
||||
debug: !production,
|
||||
optimize: production,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.(eot|svg|ttf|woff|woff2)$/,
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: 'static/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: 'public/index.html',
|
||||
favicon: 'public/favicon.png',
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index-dev.html',
|
||||
template: 'public/index-dev.html',
|
||||
favicon: 'public/favicon.png',
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
historyApiFallback: {
|
||||
index: '/index-dev.html',
|
||||
},
|
||||
index: 'index-dev.html',
|
||||
inline: true,
|
||||
overlay: true,
|
||||
open: true,
|
||||
proxy: [{
|
||||
context: ['/api', '/debug', '/serve'],
|
||||
target: 'http://localhost:9000',
|
||||
ws: true,
|
||||
}],
|
||||
stats: { colors: true },
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (argv.hot) {
|
||||
config.plugins.push(new webpack.HotModuleReplacementPlugin())
|
||||
}
|
||||
return config
|
||||
}
|
||||
2266
ui/yarn.lock
Normal file
2266
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user