mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Compare commits
156 Commits
v3.0.4
...
expandlint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae205c1b99 | ||
|
|
2b174c8b0b | ||
|
|
5729a212ce | ||
|
|
ed4a83a2bd | ||
|
|
c59e793775 | ||
|
|
40ec108daf | ||
|
|
185018e001 | ||
|
|
d62a0fede9 | ||
|
|
25c6f58535 | ||
|
|
e4ca20e471 | ||
|
|
7fa6b38b38 | ||
|
|
13ac9a6a1c | ||
|
|
0a51641a30 | ||
|
|
73203c6bcd | ||
|
|
6066be831c | ||
|
|
33784cbb94 | ||
|
|
f76b93a8f2 | ||
|
|
0361e971e0 | ||
|
|
def3e88651 | ||
|
|
8adae023dc | ||
|
|
fc8ea530bb | ||
|
|
ea585c4851 | ||
|
|
baa2dbd3a1 | ||
|
|
864a2ba2d2 | ||
|
|
b586ebe210 | ||
|
|
bed49706fc | ||
|
|
6ce1fd6347 | ||
|
|
3c0f253820 | ||
|
|
b2a77ad522 | ||
|
|
32cc4fc56d | ||
|
|
3112deb3e6 | ||
|
|
975cb4ca5e | ||
|
|
97506b2d2b | ||
|
|
8667c70257 | ||
|
|
a27b57df67 | ||
|
|
d8746e8093 | ||
|
|
599200d32e | ||
|
|
e190adef4d | ||
|
|
6a389c78cc | ||
|
|
6d66012a0c | ||
|
|
b5ccd3da51 | ||
|
|
2d409bb2b1 | ||
|
|
4262b34a20 | ||
|
|
746f3bffbd | ||
|
|
5a5864fde6 | ||
|
|
e6e4e0987d | ||
|
|
f0473c5d65 | ||
|
|
288288ee85 | ||
|
|
32b83e6345 | ||
|
|
043551343c | ||
|
|
c1d5d49126 | ||
|
|
d2121a52a9 | ||
|
|
3e94aacc20 | ||
|
|
41889ee83a | ||
|
|
1b5a783dbd | ||
|
|
208d20582e | ||
|
|
2a65c9beaa | ||
|
|
f411a42f90 | ||
|
|
13c39c8c0f | ||
|
|
1088ccb8d1 | ||
|
|
d304cbd88b | ||
|
|
e56638cbac | ||
|
|
843cb8a015 | ||
|
|
535438342e | ||
|
|
e22ed26633 | ||
|
|
1ce1674861 | ||
|
|
7ae7d29741 | ||
|
|
86d762ac88 | ||
|
|
85e1c2c7d7 | ||
|
|
3e06050771 | ||
|
|
72adb5561d | ||
|
|
20ef8af047 | ||
|
|
d7c538a210 | ||
|
|
ebd4b9504b | ||
|
|
1d102a68b8 | ||
|
|
11b581bbb5 | ||
|
|
d1e52ad971 | ||
|
|
4a6b727cbc | ||
|
|
01fb161df8 | ||
|
|
0cb62af074 | ||
|
|
3731837127 | ||
|
|
74a27875e9 | ||
|
|
b655c0cc11 | ||
|
|
163a84f353 | ||
|
|
d6c23df241 | ||
|
|
b1acff08a3 | ||
|
|
3d162549b1 | ||
|
|
d2fad433d7 | ||
|
|
beb5abc62d | ||
|
|
3709aa8b51 | ||
|
|
63e47a4e74 | ||
|
|
5eb9592637 | ||
|
|
9eabf94c48 | ||
|
|
0128be1f64 | ||
|
|
d5553030d2 | ||
|
|
f070347535 | ||
|
|
cafd2c3d66 | ||
|
|
dcd60b47dd | ||
|
|
6a30a294c6 | ||
|
|
9836c0ffbb | ||
|
|
558f3de083 | ||
|
|
00736cc704 | ||
|
|
9f0fef3180 | ||
|
|
f1dadba1b2 | ||
|
|
06ec140e72 | ||
|
|
7c13a98ad2 | ||
|
|
0ae452ed17 | ||
|
|
926f9f3804 | ||
|
|
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 |
5
.gitattributes
vendored
5
.gitattributes
vendored
@@ -1,6 +1,7 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
*.raw -text
|
||||
* text=auto
|
||||
*.golden -text
|
||||
*.raw -text
|
||||
|
||||
# Custom for Visual Studio
|
||||
*.cs diff=csharp
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
57
.github/workflows/build-and-test.yml
vendored
57
.github/workflows/build-and-test.yml
vendored
@@ -1,35 +1,78 @@
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
go-build:
|
||||
linux-go-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Linux Go ${{ matrix.go }} build
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: [ '1.18', '1.17' ]
|
||||
name: Go ${{ matrix.go }} build
|
||||
go:
|
||||
- '1.21'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
check-latest: true
|
||||
|
||||
- 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 }}
|
||||
flag-name: Linux-Go-${{ matrix.go }}
|
||||
parallel: true
|
||||
|
||||
windows-go-build:
|
||||
runs-on: windows-latest
|
||||
name: Windows Go build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -race -coverprofile="profile.cov" ./...
|
||||
|
||||
- name: Send coverage
|
||||
uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
path-to-profile: profile.cov
|
||||
flag-name: Windows-Go
|
||||
parallel: true
|
||||
|
||||
coverage:
|
||||
needs: go-build
|
||||
needs:
|
||||
- linux-go-build
|
||||
- windows-go-build
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
|
||||
43
.github/workflows/docker-build.yml
vendored
43
.github/workflows/docker-build.yml
vendored
@@ -1,18 +1,30 @@
|
||||
name: Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
workflow_dispatch: # allow for manual run
|
||||
|
||||
env:
|
||||
REGISTRY_PUSH: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Build Container'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
inbucket/inbucket
|
||||
@@ -25,30 +37,35 @@ jobs:
|
||||
type=edge,branch=main
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64, linux/arm/v7
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
20
.github/workflows/lint.yml
vendored
Normal file
20
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Lint Go Code
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
version: latest
|
||||
# Disable cache to prevent `File exists` errors.
|
||||
# https://github.com/golangci/golangci-lint-action/issues/135
|
||||
skip-pkg-cache: true
|
||||
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@@ -1,39 +1,50 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 'Go Releaser'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.18
|
||||
go-version: '1.21'
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '16.x'
|
||||
node-version: '20.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ui/yarn.lock
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
yarn install --frozen-lockfile --non-interactive
|
||||
yarn run build
|
||||
working-directory: ./ui
|
||||
|
||||
- name: Test build release
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
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@v5
|
||||
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -3,6 +3,9 @@
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Emacs messiness.
|
||||
*~
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
@@ -30,6 +33,8 @@ tags.*
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
|
||||
/.direnv
|
||||
|
||||
# Inbucket binaries
|
||||
/client
|
||||
/client.exe
|
||||
@@ -53,3 +58,10 @@ repl-temp-*
|
||||
# Dependency directories
|
||||
/ui/node_modules
|
||||
/ui/.parcel-cache
|
||||
|
||||
# Test lua files
|
||||
/inbucket.lua
|
||||
|
||||
# IntelliJ
|
||||
.idea
|
||||
inbucket.iml
|
||||
|
||||
79
.golangci.yml
Normal file
79
.golangci.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- contextcheck
|
||||
- decorder
|
||||
# - dupl
|
||||
# - dupword
|
||||
- durationcheck
|
||||
- errchkjson
|
||||
- errname
|
||||
# - errorlint
|
||||
- execinquery
|
||||
# - exhaustive
|
||||
- exportloopref
|
||||
# - forcetypeassert
|
||||
- ginkgolinter
|
||||
- gocheckcompilerdirectives
|
||||
# - gochecknoinits
|
||||
- gochecksumtype
|
||||
- gocritic
|
||||
# - godot
|
||||
# - goerr113
|
||||
- gofmt
|
||||
# - gofumpt
|
||||
- goheader
|
||||
- goimports
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
# - gosec
|
||||
- gosmopolitan
|
||||
- grouper
|
||||
- importas
|
||||
- inamedparam
|
||||
- interfacebloat
|
||||
- loggercheck
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- musttag
|
||||
- nilerr
|
||||
# - nilnil
|
||||
# - nlreturn
|
||||
- noctx
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- protogetter
|
||||
- reassign
|
||||
# - revive
|
||||
- rowserrcheck
|
||||
- sloglint
|
||||
- stylecheck
|
||||
- tagliatelle
|
||||
- tenv
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- thelper
|
||||
- tparallel
|
||||
# - unconvert
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- zerologlint
|
||||
linters-settings:
|
||||
tagliatelle:
|
||||
case:
|
||||
rules:
|
||||
json: kebab
|
||||
@@ -50,8 +50,6 @@ archives:
|
||||
- id: tarball
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||
.Arm }}{{ end }}'
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
@@ -74,7 +72,7 @@ nfpms:
|
||||
license: MIT
|
||||
contents:
|
||||
- src: "ui/dist/**"
|
||||
dst: "/usr/local/share/inbucket/ui"
|
||||
dst: "/usr/share/inbucket/ui"
|
||||
- src: "etc/linux/inbucket.service"
|
||||
dst: "/lib/systemd/system/inbucket.service"
|
||||
type: config|noreplace
|
||||
@@ -82,9 +80,6 @@ nfpms:
|
||||
dst: "/etc/inbucket/greeting.html"
|
||||
type: config|noreplace
|
||||
|
||||
snapshot:
|
||||
name_template: SNAPSHOT-{{ .Commit }}
|
||||
|
||||
checksum:
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||
|
||||
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -7,6 +7,49 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [v3.1.0-beta2] - 2024-02-05
|
||||
|
||||
### Added
|
||||
- Reject mail by origin domain: `INBUCKET_SMTP_REJECTORIGINDOMAINS` (#375)
|
||||
- Wildcard support (#412)
|
||||
- Version flag for `inbucket` cmd (#385)
|
||||
- STLS support for POP3 (#384)
|
||||
- ForceTLS flag for SMTP (#402)
|
||||
- Lua scripting additions:
|
||||
- `logger` API for Lua (#407)
|
||||
- `before.message_stored` handler (#417, #418)
|
||||
- `$` is replaced with `:` in filestore paths, for `D:\...` syntax (#449)
|
||||
- REST Client `transport` support (#463)
|
||||
|
||||
### Fixed
|
||||
- UI & Storage paths in systemd service file (#393)
|
||||
- Web UI will redirect from `prefix` to `prefix/` (#397)
|
||||
- Include inlines when listing attachments (#398)
|
||||
- Fail Inbucket startup if unable to create storage dir (#448)
|
||||
- Close directory file handles immediately, fixes Windows locking (#457)
|
||||
|
||||
|
||||
## [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
|
||||
@@ -310,7 +353,10 @@ No change from beta1.
|
||||
specific message.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.3...main
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...main
|
||||
[v3.1.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...v3.1.0-beta2
|
||||
[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
|
||||
@@ -346,9 +392,10 @@ No change from beta1.
|
||||
- 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 into main, tag `vX.Y.Z`
|
||||
6. Push tags and wait for
|
||||
5. Commit changes and merge release PR into main
|
||||
6. Create new release via GitHub, use CHANGELOG release notes, tag `vX.Y.Z`
|
||||
7. 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`
|
||||
-- it will add compiled release assets
|
||||
|
||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
### Build frontend
|
||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||
FROM --platform=linux/amd64 node:16 as frontend
|
||||
FROM --platform=linux/amd64 node:20 as frontend
|
||||
RUN npm install -g node-gyp
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
WORKDIR /build/ui
|
||||
@@ -11,7 +12,7 @@ RUN yarn install --frozen-lockfile --non-interactive
|
||||
RUN yarn run build
|
||||
|
||||
### Build backend
|
||||
FROM golang:1.18-alpine3.16 as backend
|
||||
FROM golang:1.21-alpine3.19 as backend
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
@@ -22,7 +23,7 @@ RUN go build -o inbucket \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
### Run in minimal image
|
||||
FROM alpine:3.16
|
||||
FROM alpine:3.19
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
@@ -37,7 +38,7 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||
ENV INBUCKET_WEB_UIDIR=ui
|
||||
ENV INBUCKET_WEB_UIDIR ui
|
||||
ENV INBUCKET_STORAGE_TYPE file
|
||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||
|
||||
5
Makefile
5
Makefile
@@ -9,7 +9,7 @@ commands = client inbucket
|
||||
|
||||
all: clean test lint build
|
||||
|
||||
$(commands): %: cmd/%
|
||||
$(commands): %: cmd/% $(SRC)
|
||||
go build ./$<
|
||||
|
||||
clean:
|
||||
@@ -32,8 +32,11 @@ simplify:
|
||||
@gofmt -s -l -w $(SRC)
|
||||
|
||||
lint:
|
||||
@echo "gofmt check..."
|
||||
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||
@echo "golint check..."
|
||||
@golint -set_exit_status $(PKGS)
|
||||
@echo "go vet check..."
|
||||
@go vet $(PKGS)
|
||||
|
||||
reflex:
|
||||
|
||||
@@ -77,7 +77,7 @@ version can be found at https://github.com/inbucket/inbucket
|
||||
[Configurator]: https://www.inbucket.org/configurator/
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
|
||||
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
|
||||
[Docker Image]: https://www.inbucket.org/binaries/docker.html
|
||||
[Docker Image]: https://inbucket.org/packages/docker.html
|
||||
[Elm]: https://elm-lang.org/
|
||||
[From Source]: https://www.inbucket.org/installation/from-source.html
|
||||
[Go]: https://golang.org/
|
||||
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
mailbox string
|
||||
}
|
||||
type listCmd struct{}
|
||||
|
||||
func (*listCmd) Name() string {
|
||||
return "list"
|
||||
@@ -27,22 +25,23 @@ func (*listCmd) Usage() string {
|
||||
`
|
||||
}
|
||||
|
||||
func (l *listCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
func (l *listCmd) SetFlags(f *flag.FlagSet) {}
|
||||
|
||||
func (l *listCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
|
||||
// Setup rest client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
headers, err := c.ListMailboxWithContext(ctx, mailbox)
|
||||
if err != nil {
|
||||
return fatal("REST call failed", err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
@@ -50,14 +52,17 @@ func main() {
|
||||
// Important top-level flags
|
||||
subcommands.ImportantFlag("host")
|
||||
subcommands.ImportantFlag("port")
|
||||
|
||||
// Setup standard helpers
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
|
||||
// Setup my commands
|
||||
subcommands.Register(&listCmd{}, "")
|
||||
subcommands.Register(&matchCmd{}, "")
|
||||
subcommands.Register(&mboxCmd{}, "")
|
||||
|
||||
// Parse and execute
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
@@ -65,7 +70,7 @@ func main() {
|
||||
}
|
||||
|
||||
func baseURL() string {
|
||||
return fmt.Sprintf("http://%s:%v", *host, *port)
|
||||
return "http://%s" + net.JoinHostPort(*host, strconv.FormatUint(uint64(*port), 10))
|
||||
}
|
||||
|
||||
func fatal(msg string, err error) subcommands.ExitStatus {
|
||||
|
||||
@@ -10,13 +10,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
)
|
||||
|
||||
type matchCmd struct {
|
||||
mailbox string
|
||||
output string
|
||||
outFunc func(headers []*client.MessageHeader) error
|
||||
outFunc func(ctx context.Context, headers []*client.MessageHeader) error
|
||||
delete bool
|
||||
// match criteria
|
||||
from regexFlag
|
||||
@@ -52,11 +51,12 @@ func (m *matchCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (m *matchCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
|
||||
// Select output function
|
||||
switch m.output {
|
||||
case "id":
|
||||
@@ -68,16 +68,19 @@ func (m *matchCmd) Execute(
|
||||
default:
|
||||
return usage("unknown output type: " + m.output)
|
||||
}
|
||||
|
||||
// Setup REST client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
headers, err := c.ListMailboxWithContext(ctx, mailbox)
|
||||
if err != nil {
|
||||
return fatal("List REST call failed", err)
|
||||
}
|
||||
|
||||
// Find matches
|
||||
matches := make([]*client.MessageHeader, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
@@ -85,24 +88,28 @@ func (m *matchCmd) Execute(
|
||||
matches = append(matches, h)
|
||||
}
|
||||
}
|
||||
|
||||
// Return error status if no matches
|
||||
if len(matches) == 0 {
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
// Output matches
|
||||
err = m.outFunc(matches)
|
||||
err = m.outFunc(ctx, matches)
|
||||
if err != nil {
|
||||
return fatal("Error", err)
|
||||
}
|
||||
|
||||
// Optionally, delete matches
|
||||
if m.delete {
|
||||
// Delete matches
|
||||
for _, h := range matches {
|
||||
err = h.Delete()
|
||||
err = h.DeleteWithContext(ctx)
|
||||
if err != nil {
|
||||
return fatal("Delete REST call failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
@@ -149,14 +156,14 @@ func (m *matchCmd) match(header *client.MessageHeader) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func outputID(headers []*client.MessageHeader) error {
|
||||
func outputID(_ context.Context, headers []*client.MessageHeader) error {
|
||||
for _, h := range headers {
|
||||
fmt.Println(h.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputJSON(headers []*client.MessageHeader) error {
|
||||
func outputJSON(_ context.Context, headers []*client.MessageHeader) error {
|
||||
jsonEncoder := json.NewEncoder(os.Stdout)
|
||||
jsonEncoder.SetEscapeHTML(false)
|
||||
jsonEncoder.SetIndent("", " ")
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
)
|
||||
|
||||
type mboxCmd struct {
|
||||
mailbox string
|
||||
delete bool
|
||||
delete bool
|
||||
}
|
||||
|
||||
func (*mboxCmd) Name() string {
|
||||
@@ -34,48 +33,55 @@ func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (m *mboxCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
|
||||
// Setup REST client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
headers, err := c.ListMailboxWithContext(ctx, mailbox)
|
||||
if err != nil {
|
||||
return fatal("List REST call failed", err)
|
||||
}
|
||||
err = outputMbox(headers)
|
||||
err = outputMbox(ctx, headers)
|
||||
if err != nil {
|
||||
return fatal("Error", err)
|
||||
}
|
||||
|
||||
// Optionally, delete retrieved messages
|
||||
if m.delete {
|
||||
// Delete matches
|
||||
for _, h := range headers {
|
||||
err = h.Delete()
|
||||
err = h.DeleteWithContext(ctx)
|
||||
if err != nil {
|
||||
return fatal("Delete REST call failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// outputMbox renders messages in mbox format
|
||||
// also used by match subcommand
|
||||
func outputMbox(headers []*client.MessageHeader) error {
|
||||
// outputMbox renders messages in mbox format.
|
||||
// It is also used by match subcommand.
|
||||
func outputMbox(ctx context.Context, headers []*client.MessageHeader) error {
|
||||
for _, h := range headers {
|
||||
source, err := h.GetSource()
|
||||
source, err := h.GetSourceWithContext(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Get source REST failed: %v", err)
|
||||
return fmt.Errorf("get source REST failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("From %s\n", h.From)
|
||||
// TODO Escape "From " in message bodies with >
|
||||
source.WriteTo(os.Stdout)
|
||||
if _, err := source.WriteTo(os.Stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -14,19 +14,11 @@ import (
|
||||
"syscall"
|
||||
"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/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/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage/file"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -57,6 +49,7 @@ func init() {
|
||||
func main() {
|
||||
// Command line flags.
|
||||
help := flag.Bool("help", false, "Displays help on flags and env variables.")
|
||||
versionflag := flag.Bool("version", false, "Displays version.")
|
||||
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
|
||||
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
|
||||
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
|
||||
@@ -72,6 +65,10 @@ func main() {
|
||||
config.Usage()
|
||||
return
|
||||
}
|
||||
if *versionflag {
|
||||
fmt.Fprintln(os.Stdout, version)
|
||||
return
|
||||
}
|
||||
|
||||
// Process configuration.
|
||||
config.Version = version
|
||||
@@ -114,36 +111,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,30 +132,37 @@ 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()
|
||||
}
|
||||
|
||||
// openLog configures zerolog output, returns func to close logfile.
|
||||
func openLog(level string, logfile string, json bool) (close func(), err error) {
|
||||
func openLog(level string, logfile string, json bool) (closeLog func(), err error) {
|
||||
switch level {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
@@ -189,9 +173,10 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
default:
|
||||
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
|
||||
return nil, fmt.Errorf("log level %q not one of: debug, info, warn, error", level)
|
||||
}
|
||||
close = func() {}
|
||||
|
||||
closeLog = func() {}
|
||||
var w io.Writer
|
||||
color := runtime.GOOS != "windows"
|
||||
switch logfile {
|
||||
@@ -207,21 +192,24 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
|
||||
bw := bufio.NewWriter(logf)
|
||||
w = bw
|
||||
color = false
|
||||
close = func() {
|
||||
closeLog = func() {
|
||||
_ = bw.Flush()
|
||||
_ = logf.Close()
|
||||
}
|
||||
}
|
||||
|
||||
w = zerolog.SyncWriter(w)
|
||||
if json {
|
||||
log.Logger = log.Output(w)
|
||||
return close, nil
|
||||
return closeLog, nil
|
||||
}
|
||||
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
Out: w,
|
||||
NoColor: !color,
|
||||
})
|
||||
return close, nil
|
||||
|
||||
return closeLog, nil
|
||||
}
|
||||
|
||||
// removePIDFile removes the PID file if created.
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +18,7 @@ variables it supports:
|
||||
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
|
||||
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
|
||||
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
|
||||
INBUCKET_SMTP_REJECTORIGINDOMAINS Domains to reject mail from
|
||||
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
|
||||
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||
@@ -56,6 +58,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`
|
||||
@@ -150,7 +162,7 @@ List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
|
||||
has no effect when true.
|
||||
|
||||
- Default: None
|
||||
- Values: Comma separated list of domains
|
||||
- Values: Comma separated list of recipient domains
|
||||
- Example: `localhost,mysite.org`
|
||||
|
||||
### Rejected Recipient Domain List
|
||||
@@ -161,7 +173,22 @@ List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
|
||||
has no effect when false.
|
||||
|
||||
- Default: None
|
||||
- Values: Comma separated list of domains
|
||||
- Values: Comma separated list of recipient domains
|
||||
- Example: `reject.com,gmail.com`
|
||||
|
||||
### Rejected Origin Domain List
|
||||
|
||||
`INBUCKET_SMTP_REJECTORIGINDOMAINS`
|
||||
|
||||
List of domains to reject mail from. This list is enforced regardless of the
|
||||
`INBUCKET_SMTP_DEFAULTACCEPT` value.
|
||||
|
||||
Enforcement takes place during evalation of the `MAIL FROM` SMTP command, the
|
||||
origin domain is extracted from the address presented and compared against the
|
||||
list. It does not take email headers into account.
|
||||
|
||||
- Default: None
|
||||
- Values: Comma separated list of origin domains
|
||||
- Example: `reject.com,gmail.com`
|
||||
|
||||
### Default Recipient Store Policy
|
||||
@@ -183,7 +210,7 @@ List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
|
||||
has no effect when true.
|
||||
|
||||
- Default: None
|
||||
- Values: Comma separated list of domains
|
||||
- Values: Comma separated list of recipient domains
|
||||
- Example: `localhost,mysite.org`
|
||||
|
||||
### Discarded Recipient Domain List
|
||||
@@ -196,7 +223,7 @@ emails. Messages sent to a domain other than this will be stored normally.
|
||||
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
|
||||
|
||||
- Default: None
|
||||
- Values: Comma separated list of domains
|
||||
- Values: Comma separated list of recipient domains
|
||||
- Example: `recycle.com,loadtest.org`
|
||||
|
||||
### Network Idle Timeout
|
||||
@@ -415,7 +442,8 @@ separated list of key:value pairs.
|
||||
#### `file` type parameters
|
||||
|
||||
- `path`: Operating system specific path to the directory where mail should be
|
||||
stored.
|
||||
stored. `$` characters will be replaced with `:` in the final path value,
|
||||
allowing Windows drive letters, i.e. `D$\inbucket`.
|
||||
|
||||
#### `memory` type parameters
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -28,7 +29,7 @@ fi
|
||||
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||
if ! test -f "$index"; then
|
||||
echo "$index does not exist!" >&2
|
||||
echo "Run 'npm run build' from the 'ui' directory." >&2
|
||||
echo "Run 'yarn build' from the 'ui' directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,5 +13,7 @@ of 300 messages per mailbox - the oldest messages will be deleted to stay under
|
||||
that limit.</p>
|
||||
|
||||
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
|
||||
domain will be accepted but not written to disk. Use this domain for load or
|
||||
soak testing your application.</p>
|
||||
domain will be accepted, but immediately <b>discarded</b> without being written
|
||||
to disk. Use this domain for load or soak testing your application. Inbucket
|
||||
will retain mail for any other domain by default, i.e.
|
||||
<code>@inbucket.local</code>.</p>
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,9 @@ main() {
|
||||
usage
|
||||
exit
|
||||
;;
|
||||
-b)
|
||||
build
|
||||
;;
|
||||
-r)
|
||||
reset
|
||||
;;
|
||||
@@ -38,6 +41,8 @@ main() {
|
||||
esac
|
||||
done
|
||||
|
||||
set -x
|
||||
|
||||
docker run $run_opts \
|
||||
-p $PORT_HTTP:9000 \
|
||||
-p $PORT_SMTP:2500 \
|
||||
@@ -49,14 +54,21 @@ main() {
|
||||
|
||||
usage() {
|
||||
echo "$0 [options]" 2>&1
|
||||
echo " -b build - build image before starting" 2>&1
|
||||
echo " -d detach - detach and print container ID" 2>&1
|
||||
echo " -r reset - purge config and data before startup" 2>&1
|
||||
echo " -h help - print this message" 2>&1
|
||||
}
|
||||
|
||||
build() {
|
||||
echo "Building $IMAGE"
|
||||
docker build . -t "$IMAGE"
|
||||
echo
|
||||
}
|
||||
|
||||
reset() {
|
||||
/bin/rm -rf "$VOL_CONFIG"
|
||||
/bin/rm -rf "$VOL_DATA"
|
||||
rm -rf "$VOL_CONFIG"
|
||||
rm -rf "$VOL_DATA"
|
||||
}
|
||||
|
||||
main $*
|
||||
|
||||
@@ -12,18 +12,18 @@ Environment=INBUCKET_LOGLEVEL=warn
|
||||
Environment=INBUCKET_SMTP_ADDR=0.0.0.0:2500
|
||||
Environment=INBUCKET_POP3_ADDR=0.0.0.0:1100
|
||||
Environment=INBUCKET_WEB_ADDR=0.0.0.0:9000
|
||||
Environment=INBUCKET_WEB_UIDIR=/usr/local/share/inbucket/ui
|
||||
Environment=INBUCKET_WEB_UIDIR=/usr/share/inbucket/ui
|
||||
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
|
||||
Environment=INBUCKET_STORAGE_TYPE=file
|
||||
Environment=INBUCKET_STORAGE_PARAMS=path:/var/local/inbucket
|
||||
Environment=INBUCKET_STORAGE_PARAMS=path:/var/inbucket
|
||||
|
||||
# Uncomment line below to use low numbered ports
|
||||
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbucket
|
||||
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/bin/inbucket
|
||||
|
||||
ExecStartPre=/bin/mkdir -p /var/local/inbucket
|
||||
ExecStartPre=/bin/chown daemon:daemon /var/local/inbucket
|
||||
ExecStartPre=/bin/mkdir -p /var/inbucket
|
||||
ExecStartPre=/bin/chown daemon:daemon /var/inbucket
|
||||
|
||||
ExecStart=/usr/local/bin/inbucket
|
||||
ExecStart=/usr/bin/inbucket
|
||||
|
||||
# Give SMTP connections time to drain
|
||||
TimeoutStopSec=20
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# rest-apiv1.sh
|
||||
# description: Script to access Inbucket REST API version 1
|
||||
|
||||
|
||||
43
etc/swaks-tests/mime-inline.raw
Normal file
43
etc/swaks-tests/mime-inline.raw
Normal file
@@ -0,0 +1,43 @@
|
||||
Subject: Inline attachment
|
||||
From: %FROM_ADDRESS%
|
||||
To: %TO_ADDRESS%
|
||||
Message-ID: <1234@example.com>
|
||||
Date: %DATE%
|
||||
Content-Type: multipart/mixed; boundary=boundary1
|
||||
|
||||
--boundary1
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Hello World HTML</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style=3D"color:red">Hello World</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--boundary1
|
||||
Content-Type: application/pdf; name=Hello-World.pdf
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: inline; name=Hello-World.pdf;
|
||||
filename=Hello-World.pdf
|
||||
|
||||
JVBERi0xLjQKJcK1wrYKCjEgMCBvYmoKPDwvVGl0bGUoSGVsbG8gV29ybGQpL0F1dGhvcihBZHJp
|
||||
dW0pPj4KZW5kb2JqCgoyIDAgb2JqCjw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAzIDAgUj4+CmVuZG9i
|
||||
agoKMyAwIG9iago8PC9UeXBlL1BhZ2VzL01lZGlhQm94WzAgMCA1OTUgODQyXS9SZXNvdXJjZXM8
|
||||
PC9Gb250PDwvRjEgNCAwIFI+Pi9Qcm9jU2V0Wy9QREYvVGV4dF0+Pi9LaWRzWzUgMCBSXS9Db3Vu
|
||||
dCAxPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250
|
||||
L0hlbHZldGljYS9FbmNvZGluZy9XaW5BbnNpRW5jb2Rpbmc+PgplbmRvYmoKCjUgMCBvYmoKPDwv
|
||||
VHlwZS9QYWdlL1BhcmVudCAzIDAgUi9Db250ZW50cyA2IDAgUj4+CmVuZG9iagoKNiAwIG9iago8
|
||||
PC9MZW5ndGggNTEvRmlsdGVyL0ZsYXRlRGVjb2RlPj4Kc3RyZWFtCnic03czVDCxUAhJ43IK4TI3
|
||||
UjA3MVMISeHS8EjNyclXCM8vyknRVAjJ4nIN4QIA3FcKuwplbmRzdHJlYW0KZW5kb2JqCgp4cmVm
|
||||
CjAgNwowMDAwMDAwMDAwIDY1NTM2IGYgCjAwMDAwMDAwMTYgMDAwMDAgbiAKMDAwMDAwMDA3MSAw
|
||||
MDAwMCBuIAowMDAwMDAwMTE3IDAwMDAwIG4gCjAwMDAwMDAyNDIgMDAwMDAgbiAKMDAwMDAwMDMz
|
||||
MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCgp0cmFpbGVyCjw8L1NpemUgNy9JbmZvIDEg
|
||||
MCBSL1Jvb3QgMiAwIFI+PgpzdGFydHhyZWYKNTA5CiUlRU9GCg==
|
||||
|
||||
|
||||
--boundary1--
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# run-tests.sh
|
||||
# description: Generate test emails for Inbucket
|
||||
|
||||
@@ -59,3 +59,10 @@ 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
|
||||
|
||||
# Inline attachment test
|
||||
swaks $* --data mime-inline.raw
|
||||
|
||||
55
go.mod
55
go.mod
@@ -1,23 +1,46 @@
|
||||
module github.com/inbucket/inbucket
|
||||
module github.com/inbucket/inbucket/v3
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.21.4
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||
github.com/cosmotek/loguago v1.0.0
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/jhillyerd/enmime v0.9.2
|
||||
github.com/gorilla/css v1.0.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/inbucket/gopher-json v0.2.0
|
||||
github.com/jhillyerd/enmime v1.1.0
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.17
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/rs/zerolog v1.32.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/yuin/gopher-lua v1.1.1
|
||||
golang.org/x/net v0.20.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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // 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.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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.6 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
131
go.sum
131
go.sum
@@ -2,90 +2,91 @@ 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/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cosmotek/loguago v1.0.0 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
|
||||
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/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-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
|
||||
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
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-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
|
||||
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||
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/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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
|
||||
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/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.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
|
||||
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 h1:noHsffKZsNfU38DwcXWEPldrTjIZ8FPNKx8mYMGnqjs=
|
||||
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7/go.mod h1:bbMEM6aU1WDF1ErA5YJ0p91652pGv140gGw4Ww3RGp8=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
@@ -51,46 +51,58 @@ func (n *mbNaming) Decode(v string) error {
|
||||
case "domain":
|
||||
*n = DomainNaming
|
||||
default:
|
||||
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||
return fmt.Errorf("unknown MailboxNaming strategy: %q", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
||||
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
||||
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
||||
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
||||
AcceptDomains []string `desc:"Domains to accept mail for"`
|
||||
RejectDomains []string `desc:"Domains to reject mail for"`
|
||||
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
||||
StoreDomains []string `desc:"Domains to store mail for"`
|
||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||
Debug bool `ignored:"true"`
|
||||
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
||||
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
||||
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
||||
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
||||
AcceptDomains []string `desc:"Domains to accept mail for"`
|
||||
RejectDomains []string `desc:"Domains to reject mail for"`
|
||||
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
||||
StoreDomains []string `desc:"Domains to store mail for"`
|
||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||
RejectOriginDomains []string `desc:"Domains to reject mail from"`
|
||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||
Debug bool `ignored:"true"`
|
||||
ForceTLS bool `default:"false" desc:"Listen for connections with TLS."`
|
||||
}
|
||||
|
||||
// POP3 contains the POP3 server configuration.
|
||||
type POP3 struct {
|
||||
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
|
||||
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
|
||||
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
|
||||
Debug bool `ignored:"true"`
|
||||
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
|
||||
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
|
||||
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
|
||||
Debug bool `ignored:"true"`
|
||||
TLSEnabled bool `default:"false" desc:"Enable TLS"`
|
||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
|
||||
}
|
||||
|
||||
// Web contains the HTTP server configuration.
|
||||
@@ -122,6 +134,7 @@ func Process() (*Root, error) {
|
||||
stringutil.SliceToLower(c.SMTP.RejectDomains)
|
||||
stringutil.SliceToLower(c.SMTP.StoreDomains)
|
||||
stringutil.SliceToLower(c.SMTP.DiscardDomains)
|
||||
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
|
||||
return c, err
|
||||
}
|
||||
|
||||
|
||||
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/v3/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)
|
||||
|
||||
firstGot, err := first()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *firstGot)
|
||||
|
||||
secondGot, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *secondGot)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
firstGot, err := first()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, firstGot)
|
||||
|
||||
secondGot, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *secondGot)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
firstGot, err := first()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, firstGot)
|
||||
|
||||
secondGot, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *secondGot)
|
||||
}
|
||||
|
||||
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 interface{}] 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/v3/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 firstGot, secondGot string
|
||||
first := func(s string) *bool {
|
||||
firstGot = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
secondGot = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("1", first)
|
||||
broker.AddListener("2", second)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if firstGot != want {
|
||||
t.Errorf("first got %q, want %q", firstGot, want)
|
||||
}
|
||||
if secondGot != want {
|
||||
t.Errorf("second got %q, want %q", secondGot, 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 firstGot, secondGot string
|
||||
first := func(s string) *bool {
|
||||
firstGot = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
secondGot = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("dup", first)
|
||||
broker.AddListener("dup", second)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if firstGot != "" {
|
||||
t.Errorf("first got %q, want empty string", firstGot)
|
||||
}
|
||||
if secondGot != want {
|
||||
t.Errorf("second got %q, want %q", secondGot, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRemovingListenerSuccessful(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
|
||||
// Setup listeners.
|
||||
var firstGot, secondGot string
|
||||
first := func(s string) *bool {
|
||||
firstGot = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
secondGot = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("1", first)
|
||||
broker.AddListener("2", second)
|
||||
broker.RemoveListener("1")
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if firstGot != "" {
|
||||
t.Errorf("first got %q, want empty string", firstGot)
|
||||
}
|
||||
if secondGot != want {
|
||||
t.Errorf("second got %q, want %q", secondGot, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRemovingMissingListener(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
broker.RemoveListener("doesn't crash")
|
||||
}
|
||||
33
pkg/extension/event/events.go
Normal file
33
pkg/extension/event/events.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AddressParts contains the local and domain parts of an email address.
|
||||
type AddressParts struct {
|
||||
Local string
|
||||
Domain string
|
||||
}
|
||||
|
||||
// InboundMessage contains the basic header and mailbox data for a message being received.
|
||||
type InboundMessage struct {
|
||||
Mailboxes []string
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Subject string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
35
pkg/extension/host.go
Normal file
35
pkg/extension/host.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"github.com/inbucket/inbucket/v3/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 completes.
|
||||
type Events struct {
|
||||
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
|
||||
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
|
||||
BeforeMailAccepted EventBroker[event.AddressParts, bool]
|
||||
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
135
pkg/extension/luahost/bind_inboundmessage.go
Normal file
135
pkg/extension/luahost/bind_inboundmessage.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const inboundMessageName = "inbound_message"
|
||||
|
||||
func registerInboundMessageType(ls *lua.LState) {
|
||||
mt := ls.NewTypeMetatable(inboundMessageName)
|
||||
ls.SetGlobal(inboundMessageName, mt)
|
||||
|
||||
// Static attributes.
|
||||
ls.SetField(mt, "new", ls.NewFunction(newInboundMessage))
|
||||
|
||||
// Methods.
|
||||
ls.SetField(mt, "__index", ls.NewFunction(inboundMessageIndex))
|
||||
ls.SetField(mt, "__newindex", ls.NewFunction(inboundMessageNewIndex))
|
||||
}
|
||||
|
||||
func newInboundMessage(ls *lua.LState) int {
|
||||
val := &event.InboundMessage{}
|
||||
ud := wrapInboundMessage(ls, val)
|
||||
ls.Push(ud)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func wrapInboundMessage(ls *lua.LState, val *event.InboundMessage) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(inboundMessageName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
// Checks there is an InboundMessage at stack position `pos`, else throws Lua error.
|
||||
func checkInboundMessage(ls *lua.LState, pos int) *event.InboundMessage {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if v, ok := ud.Value.(*event.InboundMessage); ok {
|
||||
return v
|
||||
}
|
||||
ls.ArgError(pos, inboundMessageName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func unwrapInboundMessage(lv lua.LValue) (*event.InboundMessage, error) {
|
||||
if ud, ok := lv.(*lua.LUserData); ok {
|
||||
if v, ok := ud.Value.(*event.InboundMessage); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("expected InboundMessage, got %q", lv.Type().String())
|
||||
}
|
||||
|
||||
// Gets a field value from InboundMessage user object. This emulates a Lua table,
|
||||
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||
func inboundMessageIndex(ls *lua.LState) int {
|
||||
m := checkInboundMessage(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "mailboxes":
|
||||
lt := &lua.LTable{}
|
||||
for _, v := range m.Mailboxes {
|
||||
lt.Append(lua.LString(v))
|
||||
}
|
||||
ls.Push(lt)
|
||||
case "from":
|
||||
ls.Push(wrapMailAddress(ls, m.From))
|
||||
case "to":
|
||||
lt := &lua.LTable{}
|
||||
for _, v := range m.To {
|
||||
addr := v
|
||||
lt.Append(wrapMailAddress(ls, addr))
|
||||
}
|
||||
ls.Push(lt)
|
||||
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 InboundMessage user object. This emulates a Lua table,
|
||||
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||
func inboundMessageNewIndex(ls *lua.LState) int {
|
||||
m := checkInboundMessage(ls, 1)
|
||||
index := ls.CheckString(2)
|
||||
|
||||
switch index {
|
||||
case "mailboxes":
|
||||
lt := ls.CheckTable(3)
|
||||
mailboxes := make([]string, 0, 16)
|
||||
lt.ForEach(func(k, lv lua.LValue) {
|
||||
if mb, ok := lv.(lua.LString); ok {
|
||||
mailboxes = append(mailboxes, string(mb))
|
||||
}
|
||||
})
|
||||
m.Mailboxes = mailboxes
|
||||
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 {
|
||||
// TODO should fail if wrong type + test.
|
||||
if entry, ok := unwrapMailAddress(ud); ok {
|
||||
to = append(to, entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
m.To = to
|
||||
case "subject":
|
||||
m.Subject = ls.CheckString(3)
|
||||
case "size":
|
||||
ls.RaiseError("size is read-only")
|
||||
default:
|
||||
ls.RaiseError("invalid index %q", index)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
93
pkg/extension/luahost/bind_inboundmessage_test.go
Normal file
93
pkg/extension/luahost/bind_inboundmessage_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// LuaInit holds useful test globals.
|
||||
const LuaInit = `
|
||||
function assert_eq(got, want)
|
||||
if type(got) == "table" and type(want) == "table" then
|
||||
assert(#got == #want, string.format("got %d element(s), 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(got == want, string.format("got %q, wanted %q", got, want))
|
||||
end
|
||||
`
|
||||
|
||||
func TestInboundMessageGetters(t *testing.T) {
|
||||
want := &event.InboundMessage{
|
||||
Mailboxes: []string{"mb1", "mb2"},
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{
|
||||
{Name: "name2", Address: "addr2"},
|
||||
{Name: "name3", Address: "addr3"},
|
||||
},
|
||||
Subject: "subj1",
|
||||
Size: 42,
|
||||
}
|
||||
script := `
|
||||
assert(msg, "msg should not be nil")
|
||||
|
||||
assert_eq(msg.mailboxes, {"mb1", "mb2"})
|
||||
assert_eq(msg.subject, "subj1")
|
||||
assert_eq(msg.size, 42)
|
||||
|
||||
assert_eq(msg.from.name, "name1")
|
||||
assert_eq(msg.from.address, "addr1")
|
||||
|
||||
assert_eq(#msg.to, 2)
|
||||
assert_eq(msg.to[1].name, "name2")
|
||||
assert_eq(msg.to[1].address, "addr2")
|
||||
assert_eq(msg.to[2].name, "name3")
|
||||
assert_eq(msg.to[2].address, "addr3")
|
||||
`
|
||||
|
||||
ls := lua.NewState()
|
||||
registerInboundMessageType(ls)
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
|
||||
require.NoError(t, ls.DoString(LuaInit+script))
|
||||
}
|
||||
|
||||
func TestInboundMessageSetters(t *testing.T) {
|
||||
want := &event.InboundMessage{
|
||||
Mailboxes: []string{"mb1", "mb2"},
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{
|
||||
{Name: "name2", Address: "addr2"},
|
||||
{Name: "name3", Address: "addr3"},
|
||||
},
|
||||
Subject: "subj1",
|
||||
}
|
||||
script := `
|
||||
assert(msg, "msg should not be nil")
|
||||
|
||||
msg.mailboxes = {"mb1", "mb2"}
|
||||
msg.subject = "subj1"
|
||||
msg.from = address.new("name1", "addr1")
|
||||
msg.to = { address.new("name2", "addr2"), address.new("name3", "addr3") }
|
||||
`
|
||||
|
||||
got := &event.InboundMessage{}
|
||||
ls := lua.NewState()
|
||||
registerInboundMessageType(ls)
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("msg", wrapInboundMessage(ls, got))
|
||||
require.NoError(t, ls.DoString(script))
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
223
pkg/extension/luahost/bind_inbucket.go
Normal file
223
pkg/extension/luahost/bind_inbucket.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
MessageStored *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))
|
||||
case "message_stored":
|
||||
ls.Push(funcOrNil(before.MessageStored))
|
||||
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)
|
||||
case "message_stored":
|
||||
m.MessageStored = 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", "message_stored" }
|
||||
|
||||
-- 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))
|
||||
}
|
||||
120
pkg/extension/luahost/bind_message.go
Normal file
120
pkg/extension/luahost/bind_message.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/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 {
|
||||
// TODO should fail if wrong type + test.
|
||||
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/v3/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)
|
||||
}
|
||||
233
pkg/extension/luahost/lua.go
Normal file
233
pkg/extension/luahost/lua.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
)
|
||||
|
||||
// ErrNoScript signals that the Lua script file was not present.
|
||||
var ErrNoScript error = errors.New("no script file present")
|
||||
|
||||
// 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, ErrNoScript
|
||||
} 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return NewFromReader(logContext.Logger(), 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(logger zerolog.Logger, extHost *extension.Host, r io.Reader, path string) (*Host, error) {
|
||||
startLogger := logger.With().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(logger, proto)
|
||||
h := &Host{extHost: extHost, pool: pool, logContext: logger.With()}
|
||||
if ls, err := pool.getState(); err == nil {
|
||||
h.wireFunctions(startLogger, 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)
|
||||
}
|
||||
if ib.Before.MessageStored != nil {
|
||||
events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored)
|
||||
}
|
||||
}
|
||||
|
||||
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("before.mail_accepted")
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.InboundMessage {
|
||||
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.message_stored")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
defer h.pool.putState(ls)
|
||||
|
||||
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||
if err := ls.CallByParam(
|
||||
lua.P{Fn: ib.Before.MessageStored, NRet: 1, Protect: true},
|
||||
wrapInboundMessage(ls, &msg),
|
||||
); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||
return nil
|
||||
}
|
||||
|
||||
lval := ls.Get(1)
|
||||
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
|
||||
|
||||
if lval.Type() == lua.LTNil || lua.LVIsFalse(lval) {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := unwrapInboundMessage(lval)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Bad response from Lua Function")
|
||||
}
|
||||
ls.Pop(1)
|
||||
|
||||
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
|
||||
}
|
||||
258
pkg/extension/luahost/lua_test.go
Normal file
258
pkg/extension/luahost/lua_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package luahost_test
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// LuaInit holds useful test globals.
|
||||
const LuaInit = `
|
||||
local logger = require("logger")
|
||||
|
||||
async = false
|
||||
test_ok = true
|
||||
|
||||
-- Sends marks tests as failed instead of erroring when enabled.
|
||||
function assert_async(value, message)
|
||||
if not value then
|
||||
if async then
|
||||
logger.error(message, {from = "assert_async"})
|
||||
test_ok = false
|
||||
else
|
||||
error(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Verifies 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
|
||||
|
||||
-- Verifies string want contains string got.
|
||||
function assert_contains(got, want)
|
||||
assert_async(string.find(got, want),
|
||||
string.format("got %q, wanted it to contain %q", got, want))
|
||||
end
|
||||
`
|
||||
|
||||
var consoleLogger = zerolog.New(zerolog.NewConsoleWriter())
|
||||
|
||||
func TestEmptyScript(t *testing.T) {
|
||||
script := ""
|
||||
extHost := extension.NewHost()
|
||||
|
||||
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
script := `
|
||||
local logger = require("logger")
|
||||
logger.info("_test log entry_", {})
|
||||
`
|
||||
|
||||
extHost := extension.NewHost()
|
||||
output := &strings.Builder{}
|
||||
logger := zerolog.New(output)
|
||||
|
||||
_, err := luahost.NewFromReader(logger, extHost, strings.NewReader(script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, output.String(), "_test log entry_")
|
||||
}
|
||||
|
||||
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(consoleLogger, 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(consoleLogger, 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(consoleLogger, 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, "Expected result from Emit()")
|
||||
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, "Expected result from Emit()")
|
||||
if *got != want {
|
||||
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeMessageStored(t *testing.T) {
|
||||
// Event to send.
|
||||
msg := event.InboundMessage{
|
||||
Mailboxes: []string{"one", "two"},
|
||||
From: &mail.Address{Name: "From Name", Address: "from@example.com"},
|
||||
To: []*mail.Address{
|
||||
{Name: "To1 Name", Address: "to1@example.com"},
|
||||
{Name: "To2 Name", Address: "to2@example.com"},
|
||||
},
|
||||
Subject: "inbound subj",
|
||||
Size: 42,
|
||||
}
|
||||
|
||||
// Register lua event listener.
|
||||
script := `
|
||||
async = true
|
||||
|
||||
function inbucket.before.message_stored(msg)
|
||||
-- Verify incoming values.
|
||||
assert_eq(msg.mailboxes, {"one", "two"})
|
||||
assert_eq(msg.from.name, "From Name")
|
||||
assert_eq(msg.from.address, "from@example.com")
|
||||
assert_eq(2, #msg.to)
|
||||
assert_eq(msg.to[1].name, "To1 Name")
|
||||
assert_eq(msg.to[1].address, "to1@example.com")
|
||||
assert_eq(msg.to[2].name, "To2 Name")
|
||||
assert_eq(msg.to[2].address, "to2@example.com")
|
||||
assert_eq(msg.subject, "inbound subj")
|
||||
assert_eq(msg.size, 42)
|
||||
notify:send(test_ok)
|
||||
|
||||
-- Generate response.
|
||||
res = inbound_message.new()
|
||||
res.mailboxes = {"resone", "restwo"}
|
||||
res.from = address.new("Res From", "res@example.com")
|
||||
res.to = {
|
||||
address.new("To1 Res", "res1@example.com"),
|
||||
address.new("To2 Res", "res2@example.com"),
|
||||
}
|
||||
res.subject = "res subj"
|
||||
return res
|
||||
end
|
||||
`
|
||||
extHost := extension.NewHost()
|
||||
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
notify := luaHost.CreateChannel("notify")
|
||||
|
||||
// Send event to be accepted.
|
||||
got := extHost.Events.BeforeMessageStored.Emit(&msg)
|
||||
require.NotNil(t, got, "Expected result from Emit()")
|
||||
|
||||
// Verify Lua assertions passed.
|
||||
assertNotified(t, notify)
|
||||
|
||||
// Verify response values.
|
||||
want := &event.InboundMessage{
|
||||
Mailboxes: []string{"resone", "restwo"},
|
||||
From: &mail.Address{Name: "Res From", Address: "res@example.com"},
|
||||
To: []*mail.Address{
|
||||
{Name: "To1 Res", Address: "res1@example.com"},
|
||||
{Name: "To2 Res", Address: "res2@example.com"},
|
||||
},
|
||||
Subject: "res subj",
|
||||
Size: 0,
|
||||
}
|
||||
assert.Equal(t, want, got, "Response InboundMessage did not match")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
112
pkg/extension/luahost/pool.go
Normal file
112
pkg/extension/luahost/pool.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/cjoudrey/gluahttp"
|
||||
"github.com/cosmotek/loguago"
|
||||
json "github.com/inbucket/gopher-json"
|
||||
"github.com/rs/zerolog"
|
||||
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.
|
||||
logger zerolog.Logger // Logger exported to Lua scripts.
|
||||
}
|
||||
|
||||
func newStatePool(logger zerolog.Logger, funcProto *lua.FunctionProto) *statePool {
|
||||
return &statePool{
|
||||
funcProto: funcProto,
|
||||
channels: make(map[string]chan lua.LValue),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// newState creates a new LState and configures it. Lock must be held.
|
||||
func (lp *statePool) newState() (*lua.LState, error) {
|
||||
ls := lua.NewState()
|
||||
|
||||
logger := loguago.NewLogger(lp.logger)
|
||||
|
||||
// Load supplemental native modules.
|
||||
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
|
||||
ls.PreloadModule("json", json.Loader)
|
||||
ls.PreloadModule("logger", logger.Loader)
|
||||
|
||||
// Setup channels.
|
||||
for name, ch := range lp.channels {
|
||||
ls.SetGlobal(name, lua.LChannel(ch))
|
||||
}
|
||||
|
||||
// Register custom types.
|
||||
registerInboundMessageType(ls)
|
||||
registerInbucketTypes(ls)
|
||||
registerMailAddressType(ls)
|
||||
registerMessageMetadataType(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
|
||||
}
|
||||
102
pkg/extension/luahost/pool_test.go
Normal file
102
pkg/extension/luahost/pool_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"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(zerolog.Nop(), 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.Empty(t, 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.Empty(t, pool.states, "Wanted pool to be empty")
|
||||
|
||||
a.Close()
|
||||
pool.putState(a)
|
||||
assert.Empty(t, pool.states, "Wanted pool to remain empty")
|
||||
}
|
||||
|
||||
func TestPoolPutClearsStack(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
|
||||
ls, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, 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.Len(t, pool.states, 1, "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())
|
||||
}
|
||||
@@ -2,29 +2,32 @@ package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// recvdTimeFmt to use in generated Received header.
|
||||
const recvdTimeFmt = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||
|
||||
// Manager is the interface controllers use to interact with messages.
|
||||
type Manager interface {
|
||||
Deliver(
|
||||
to *policy.Recipient,
|
||||
from string,
|
||||
from *policy.Origin,
|
||||
recipients []*policy.Recipient,
|
||||
prefix string,
|
||||
recvdHeader string,
|
||||
content []byte,
|
||||
) (id string, err error)
|
||||
GetMetadata(mailbox string) ([]*Metadata, error)
|
||||
) error
|
||||
GetMetadata(mailbox string) ([]*event.MessageMetadata, error)
|
||||
GetMessage(mailbox, id string) (*Message, error)
|
||||
MarkSeen(mailbox, id string) error
|
||||
PurgeMessages(mailbox string) error
|
||||
@@ -37,74 +40,115 @@ 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.
|
||||
func (s *StoreManager) Deliver(
|
||||
to *policy.Recipient,
|
||||
from string,
|
||||
from *policy.Origin,
|
||||
recipients []*policy.Recipient,
|
||||
prefix string,
|
||||
recvdHeader string,
|
||||
source []byte,
|
||||
) (string, error) {
|
||||
// TODO enmime is too heavy for this step, only need header.
|
||||
// Go's header parsing isn't good enough, so this is blocked on enmime issue #64.
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(source))
|
||||
) error {
|
||||
logger := log.With().Str("module", "message").Logger()
|
||||
|
||||
// Parse envelope headers.
|
||||
header, err := enmime.DecodeHeaders(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
fromaddr, err := env.AddressList("From")
|
||||
if err != nil || len(fromaddr) == 0 {
|
||||
fromaddr = []*mail.Address{{Address: from}}
|
||||
|
||||
fromAddrs, err := enmime.ParseAddressList(header.Get("From"))
|
||||
if err != nil || len(fromAddrs) == 0 {
|
||||
// Failed to parse From header, use SMTP MAIL FROM instead.
|
||||
fromAddrs = make([]*mail.Address, 1)
|
||||
fromAddrs[0] = &from.Address
|
||||
}
|
||||
toaddr, err := env.AddressList("To")
|
||||
|
||||
toAddrs, err := enmime.ParseAddressList(header.Get("To"))
|
||||
if err != nil {
|
||||
toaddr = make([]*mail.Address, len(recipients))
|
||||
// Failed to parse To header, use SMTP RCPT TO instead.
|
||||
toAddrs = make([]*mail.Address, len(recipients))
|
||||
for i, torecip := range recipients {
|
||||
toaddr[i] = &torecip.Address
|
||||
toAddrs[i] = &torecip.Address
|
||||
}
|
||||
}
|
||||
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
||||
delivery := &Delivery{
|
||||
Meta: Metadata{
|
||||
Mailbox: to.Mailbox,
|
||||
From: fromaddr[0],
|
||||
To: toaddr,
|
||||
Date: time.Now(),
|
||||
Subject: env.GetHeader("Subject"),
|
||||
},
|
||||
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
|
||||
|
||||
subject := header.Get("Subject")
|
||||
now := time.Now()
|
||||
tstamp := now.UTC().Format(recvdTimeFmt)
|
||||
|
||||
// Process inbound message through extensions.
|
||||
mailboxes := make([]string, 0, len(recipients))
|
||||
for _, recip := range recipients {
|
||||
mailboxes = append(mailboxes, recip.Mailbox)
|
||||
}
|
||||
id, err := s.Store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
// Construct InboundMessage event and process through extensions.
|
||||
inbound := &event.InboundMessage{
|
||||
Mailboxes: mailboxes,
|
||||
From: fromAddrs[0],
|
||||
To: toAddrs,
|
||||
Subject: subject,
|
||||
Size: int64(len(source)),
|
||||
}
|
||||
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(),
|
||||
|
||||
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
|
||||
if extResult == nil {
|
||||
// Use address policy to determine deliverable mailboxes.
|
||||
mailboxes = mailboxes[:0]
|
||||
for _, recip := range recipients {
|
||||
if recip.ShouldStore() {
|
||||
mailboxes = append(mailboxes, recip.Mailbox)
|
||||
}
|
||||
}
|
||||
s.Hub.Dispatch(broadcast)
|
||||
inbound.Mailboxes = mailboxes
|
||||
} else {
|
||||
// Event response overrides destination mailboxes and address policy.
|
||||
inbound = extResult
|
||||
}
|
||||
return id, nil
|
||||
|
||||
// Deliver to each mailbox.
|
||||
for _, mb := range inbound.Mailboxes {
|
||||
// Append recipient and timestamp to generated Received header.
|
||||
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
|
||||
|
||||
// Deliver message.
|
||||
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
|
||||
delivery := &Delivery{
|
||||
Meta: event.MessageMetadata{
|
||||
Mailbox: mb,
|
||||
From: inbound.From,
|
||||
To: inbound.To,
|
||||
Date: now,
|
||||
Subject: inbound.Subject,
|
||||
},
|
||||
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
|
||||
}
|
||||
id, err := s.Store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
logger.Error().Str("mailbox", mb).Err(err).Msg("Delivery failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Emit message stored event.
|
||||
event := delivery.Meta
|
||||
event.ID = id
|
||||
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||
}
|
||||
|
||||
return 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 +168,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 +203,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(),
|
||||
|
||||
552
pkg/message/manager_test.go
Normal file
552
pkg/message/manager_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeliverStoresMessages(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Attempt to deliver a message to two mailboxes.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte(`From: from@example.com
|
||||
To: u1@example.com, u2@example.com
|
||||
Subject: tsub
|
||||
|
||||
test email`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverStoresMessageNoFromHeader(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Attempt to deliver a message to two mailboxes.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte(`To: u1@example.com, u2@example.com
|
||||
Subject: tsub
|
||||
|
||||
test email`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverStoresMessageNoToHeader(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Attempt to deliver a message to two mailboxes.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte(`From: from@example.com
|
||||
Subject: tsub
|
||||
|
||||
test email`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverRespectsRecipientPolicy(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Attempt to deliver a message to two mailboxes.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@nostore.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Expect empty mailbox for nostore domain.
|
||||
assertMessageCount(t, sm, "u1@nostore.com", 0)
|
||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverEmitsBeforeMessageStoredEventToHeader(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive event.
|
||||
var got *event.InboundMessage
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
got = &msg
|
||||
return nil
|
||||
})
|
||||
|
||||
// Deliver a message to trigger event, To header differs from RCPT TO.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte(`From: from@example.com
|
||||
To: u1@example.com, u3@external.com
|
||||
Subject: tsub
|
||||
|
||||
test email`),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
|
||||
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
|
||||
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
|
||||
assert.Equal(t, []*mail.Address{
|
||||
{Name: "", Address: "u1@example.com"},
|
||||
{Name: "", Address: "u3@external.com"},
|
||||
}, got.To, "To not equal")
|
||||
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
|
||||
assert.Equal(t, int64(84), got.Size, "Size not equal")
|
||||
}
|
||||
|
||||
func TestDeliverEmitsBeforeMessageStoredEventRcptTo(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive event.
|
||||
var got *event.InboundMessage
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
got = &msg
|
||||
return nil
|
||||
})
|
||||
|
||||
// Deliver a message to trigger event, lacks To header.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
|
||||
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
|
||||
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
|
||||
assert.Equal(t, []*mail.Address{
|
||||
{Name: "", Address: "u1@example.com"},
|
||||
{Name: "", Address: "u2@example.com"},
|
||||
}, got.To, "To not equal")
|
||||
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
|
||||
assert.Equal(t, int64(48), got.Size, "Size not equal")
|
||||
}
|
||||
|
||||
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxes(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive event.
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
// Listener rewrites destination mailboxes.
|
||||
resp := msg
|
||||
resp.Mailboxes = []string{"new1@example.com", "new2@nostore.com"}
|
||||
return &resp
|
||||
})
|
||||
|
||||
// Deliver a message to trigger event.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\r\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Expect messages in only the mailboxes in the event response, and for the DiscardDomains
|
||||
// policy to be ignored for nostore.com.
|
||||
assertMessageCount(t, sm, "u1@example.com", 0)
|
||||
assertMessageCount(t, sm, "u2@example.com", 0)
|
||||
assertMessageCount(t, sm, "new1@example.com", 1)
|
||||
assertMessageCount(t, sm, "new2@nostore.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxesEmpty(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive event.
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
// Listener clears destination mailboxes.
|
||||
resp := msg
|
||||
resp.Mailboxes = []string{}
|
||||
return &resp
|
||||
})
|
||||
|
||||
// Deliver a message to trigger event.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\r\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Expect no messages the mailboxes.
|
||||
assertMessageCount(t, sm, "u1@example.com", 0)
|
||||
assertMessageCount(t, sm, "u2@example.com", 0)
|
||||
}
|
||||
|
||||
func TestDeliverUsesBeforeMessageStoredEventResponseFields(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive event.
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
// Listener rewrites destination mailboxes.
|
||||
msg.Subject = "event subj"
|
||||
msg.From = &mail.Address{Address: "from@event.com", Name: "From Event"}
|
||||
|
||||
// Changing To does not affect destination mailbox(es).
|
||||
msg.To = []*mail.Address{
|
||||
{Address: "to@event.com", Name: "To Event"},
|
||||
{Address: "to2@event.com", Name: "To 2 Event"},
|
||||
}
|
||||
|
||||
// Size is read only, should have no effect.
|
||||
msg.Size = 12345
|
||||
|
||||
return &msg
|
||||
})
|
||||
|
||||
// Deliver a message to trigger event.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1},
|
||||
"Received: xyz\r\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify single message stored.
|
||||
metadata, err := sm.GetMetadata("u1@example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, metadata, 1, "mailbox has incorrect # of messages")
|
||||
got := metadata[0]
|
||||
|
||||
// Verify metadata fields were overridden by event response values.
|
||||
assert.Equal(t, "event subj", got.Subject, "Subject didn't match")
|
||||
assert.Equal(t, "from@event.com", got.From.Address, "From Address didn't match")
|
||||
assert.Equal(t, "From Event", got.From.Name, "From Name didn't match")
|
||||
require.Len(t, got.To, 2)
|
||||
assert.Equal(t, "to@event.com", got.To[0].Address, "To Address didn't match")
|
||||
assert.Equal(t, "To Event", got.To[0].Name, "To Name didn't match")
|
||||
assert.Equal(t, "to2@event.com", got.To[1].Address, "To Address didn't match")
|
||||
assert.Equal(t, "To 2 Event", got.To[1].Name, "To Name didn't match")
|
||||
assert.NotEqual(t, 12345, got.Size, "Size is read only")
|
||||
}
|
||||
|
||||
func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||
|
||||
// Deliver a message to trigger event.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip, _ := sm.AddrPolicy.NewRecipient("to@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip},
|
||||
"Received: xyz\n",
|
||||
[]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")
|
||||
assertMessageCount(t, sm, "to@example.com", 1)
|
||||
}
|
||||
|
||||
func TestDeliverBeforeAndAfterMessageStoredEvents(t *testing.T) {
|
||||
sm, extHost := testStoreManager()
|
||||
|
||||
// Register function to receive Before event.
|
||||
extHost.Events.BeforeMessageStored.AddListener(
|
||||
"test",
|
||||
func(msg event.InboundMessage) *event.InboundMessage {
|
||||
// Listener rewrites destination mailboxes.
|
||||
resp := msg
|
||||
resp.Mailboxes = []string{"new1@example.com", "new2@example.com"}
|
||||
return &resp
|
||||
})
|
||||
|
||||
// After event listener.
|
||||
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 2)
|
||||
|
||||
// Deliver a message to trigger events.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||
if err := sm.Deliver(
|
||||
origin,
|
||||
[]*policy.Recipient{recip1, recip2},
|
||||
"Received: xyz\r\n",
|
||||
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Confirm mailbox names overridden by `Before` were sent to `After` event. Order is
|
||||
// not guaranteed.
|
||||
got1, err := listener()
|
||||
require.NoError(t, err)
|
||||
got2, err := listener()
|
||||
require.NoError(t, err)
|
||||
got := []string{got1.Mailbox, got2.Mailbox}
|
||||
assert.Contains(t, got, "new1@example.com")
|
||||
assert.Contains(t, got, "new2@example.com")
|
||||
}
|
||||
|
||||
func TestGetMessage(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Add a test message.
|
||||
subject := "getMessage1"
|
||||
id := addTestMessage(sm, "get-box", subject)
|
||||
|
||||
// Verify retrieval of the test message.
|
||||
msg, err := sm.GetMessage("get-box", id)
|
||||
require.NoError(t, err, "GetMessage must succeed")
|
||||
require.NotNil(t, msg, "GetMessage must return a result")
|
||||
assert.Equal(t, subject, msg.Subject)
|
||||
assert.Contains(t, msg.Text(), fmt.Sprintf("about %q", subject))
|
||||
}
|
||||
|
||||
func TestMarkSeen(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Add a test message.
|
||||
subject := "getMessage1"
|
||||
id := addTestMessage(sm, "seen-box", subject)
|
||||
|
||||
// Verify test message unseen.
|
||||
msg, err := sm.GetMessage("seen-box", id)
|
||||
require.NoError(t, err, "GetMessage must succeed")
|
||||
require.NotNil(t, msg, "GetMessage must return a result")
|
||||
assert.False(t, msg.Seen, "msg should be unseen")
|
||||
|
||||
err = sm.MarkSeen("seen-box", id)
|
||||
require.NoError(t, err, "MarkSeen should succeed")
|
||||
|
||||
// Verify test message seen.
|
||||
msg, err = sm.GetMessage("seen-box", id)
|
||||
require.NoError(t, err, "GetMessage must succeed")
|
||||
require.NotNil(t, msg, "GetMessage must return a result")
|
||||
assert.True(t, msg.Seen, "msg should have been seen")
|
||||
}
|
||||
|
||||
func TestRemoveMessage(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Add test messages.
|
||||
id1 := addTestMessage(sm, "rm-box", "subject 1")
|
||||
id2 := addTestMessage(sm, "rm-box", "subject 2")
|
||||
id3 := addTestMessage(sm, "rm-box", "subject 3")
|
||||
got, err := sm.GetMetadata("rm-box")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
|
||||
// Delete message 2 and verify.
|
||||
err = sm.RemoveMessage("rm-box", id2)
|
||||
require.NoError(t, err)
|
||||
got, err = sm.GetMetadata("rm-box")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2, "Should be 2 messages remaining")
|
||||
|
||||
gotIDs := make([]string, 0, 3)
|
||||
for _, msg := range got {
|
||||
gotIDs = append(gotIDs, msg.ID)
|
||||
}
|
||||
assert.Contains(t, gotIDs, id1)
|
||||
assert.Contains(t, gotIDs, id3)
|
||||
}
|
||||
|
||||
func TestPurgeMessages(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
// Add test messages.
|
||||
_ = addTestMessage(sm, "purge-box", "subject 1")
|
||||
_ = addTestMessage(sm, "purge-box", "subject 2")
|
||||
_ = addTestMessage(sm, "purge-box", "subject 3")
|
||||
got, err := sm.GetMetadata("purge-box")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
|
||||
// Purge and verify.
|
||||
err = sm.PurgeMessages("purge-box")
|
||||
require.NoError(t, err)
|
||||
got, err = sm.GetMetadata("purge-box")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "Purge should remove all mailbox messages")
|
||||
}
|
||||
|
||||
func TestSourceReader(t *testing.T) {
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
recvdHeader := "Received: xyz\n"
|
||||
msgSource := `From: from@example.com
|
||||
To: u1@example.com, u2@example.com
|
||||
Subject: tsub
|
||||
|
||||
test email`
|
||||
|
||||
// Deliver mesage.
|
||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||
err := sm.Deliver(origin, []*policy.Recipient{recip1}, recvdHeader, []byte(msgSource))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find message ID.
|
||||
msgs, err := sm.GetMetadata("u1@example.com")
|
||||
require.NoError(t, err, "Failed to read mailbox")
|
||||
require.Len(t, msgs, 1, "Unexpected mailbox len")
|
||||
id := msgs[0].ID
|
||||
|
||||
// Read back and verify source.
|
||||
r, err := sm.SourceReader("u1@example.com", id)
|
||||
require.NoError(t, err, "SourceReader must succeed")
|
||||
gotBytes, err := io.ReadAll(r)
|
||||
require.NoError(t, err, "Failed to read source")
|
||||
|
||||
got := string(gotBytes)
|
||||
assert.Contains(t, got, recvdHeader, "Source should contain received header")
|
||||
assert.Contains(t, got, msgSource, "Source should contain original message source")
|
||||
}
|
||||
|
||||
func TestMailboxForAddress(t *testing.T) {
|
||||
// Configured for FullNaming.
|
||||
sm, _ := testStoreManager()
|
||||
|
||||
addr := "u1@example.com"
|
||||
got, err := sm.MailboxForAddress(addr)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, addr, got, "FullNaming mode should return a full address for mailbox")
|
||||
}
|
||||
|
||||
// Returns an empty StoreManager and extension Host pair, configured for testing.
|
||||
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
||||
extHost := extension.NewHost()
|
||||
|
||||
sm := &message.StoreManager{
|
||||
AddrPolicy: &policy.Addressing{
|
||||
Config: &config.Root{
|
||||
MailboxNaming: config.FullNaming,
|
||||
SMTP: config.SMTP{
|
||||
DefaultAccept: true,
|
||||
DefaultStore: true,
|
||||
RejectDomains: []string{"noaccept.com"},
|
||||
DiscardDomains: []string{"nostore.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Store: test.NewStore(),
|
||||
ExtHost: extHost,
|
||||
}
|
||||
|
||||
return sm, extHost
|
||||
}
|
||||
|
||||
// Adds a test message to the provided store, returning the new message ID.
|
||||
func addTestMessage(sm *message.StoreManager, mailbox string, subject string) string {
|
||||
from := mail.Address{Name: "From Test", Address: "from@example.com"}
|
||||
to := mail.Address{Name: "To Test", Address: "to@example.com"}
|
||||
delivery := &message.Delivery{
|
||||
Meta: event.MessageMetadata{
|
||||
Mailbox: mailbox,
|
||||
From: &from,
|
||||
To: []*mail.Address{&to},
|
||||
Date: time.Now(),
|
||||
Subject: subject,
|
||||
},
|
||||
Reader: strings.NewReader(fmt.Sprintf(
|
||||
"From: %s\nTo: %s\nSubject: %s\n\nTest message about %q\n",
|
||||
from, to, subject, subject,
|
||||
)),
|
||||
}
|
||||
|
||||
id, err := sm.Store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func assertMessageCount(t *testing.T, sm *message.StoreManager, mailbox string, count int) {
|
||||
t.Helper()
|
||||
|
||||
metas, err := sm.GetMetadata(mailbox)
|
||||
require.NoError(t, err, "StoreManager GetMetadata failed")
|
||||
|
||||
got := len(metas)
|
||||
if got != count {
|
||||
t.Errorf("Mailbox %q got %v messages, wanted %v", mailbox, got, count)
|
||||
}
|
||||
}
|
||||
@@ -3,44 +3,34 @@ package message
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/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,
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments returns the MIME attachments for the message.
|
||||
func (m *Message) Attachments() []*enmime.Part {
|
||||
return m.env.Attachments
|
||||
attachments := append([]*enmime.Part{}, m.env.Inlines...)
|
||||
attachments = append(attachments, m.env.Attachments...)
|
||||
return attachments
|
||||
}
|
||||
|
||||
// Header returns the header map for this message.
|
||||
@@ -65,7 +55,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
|
||||
}
|
||||
|
||||
@@ -108,7 +98,7 @@ func (d *Delivery) Size() int64 {
|
||||
|
||||
// Source contains the raw content of the message.
|
||||
func (d *Delivery) Source() (io.ReadCloser, error) {
|
||||
return ioutil.NopCloser(d.Reader), nil
|
||||
return io.NopCloser(d.Reader), nil
|
||||
}
|
||||
|
||||
// Seen getter.
|
||||
|
||||
@@ -3,26 +3,19 @@ package msghub
|
||||
import (
|
||||
"container/ring"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/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))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,8 +140,22 @@ func (hub *Hub) RemoveListener(l Listener) {
|
||||
// for unit tests.
|
||||
func (hub *Hub) Sync() {
|
||||
done := make(chan struct{})
|
||||
hub.opChan <- func(h *Hub) {
|
||||
hub.opChan <- func(_ *Hub) {
|
||||
close(done)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
func (hub *Hub) runOp(op func(*Hub)) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if err, ok := r.(error); ok {
|
||||
log.Error().Str("module", "msghub").Err(err).Msg("Operation panicked")
|
||||
} else {
|
||||
log.Error().Str("module", "msghub").Err(err).Msgf("Operation panicked: %s", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
op(hub)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,25 @@ package msghub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/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 +28,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 +42,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 {
|
||||
return fmt.Errorf("Too many messages")
|
||||
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
|
||||
return errors.New("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 +78,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 +121,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 +144,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 +171,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 +212,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 +305,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)
|
||||
|
||||
@@ -2,12 +2,14 @@ package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
)
|
||||
|
||||
// Addressing handles email address policy.
|
||||
@@ -17,44 +19,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)
|
||||
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 "", 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 +63,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,
|
||||
@@ -77,6 +72,21 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
|
||||
// not To headers.
|
||||
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
|
||||
local, domain, err := ParseEmailAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Origin{
|
||||
Address: mail.Address{Address: address},
|
||||
addrPolicy: a,
|
||||
LocalPart: local,
|
||||
Domain: domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
|
||||
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
|
||||
domain = strings.ToLower(domain)
|
||||
@@ -105,6 +115,19 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ShouldAcceptOriginDomain indicates if Inbucket accept mail from the specified domain.
|
||||
func (a *Addressing) ShouldAcceptOriginDomain(domain string) bool {
|
||||
domain = strings.ToLower(domain)
|
||||
if len(a.Config.SMTP.RejectOriginDomains) > 0 {
|
||||
for _, d := range a.Config.SMTP.RejectOriginDomains {
|
||||
if stringutil.MatchWithWildcards(d, domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
|
||||
// An error is returned if the local or domain parts fail validation following the guidelines
|
||||
// in RFC3696.
|
||||
@@ -114,7 +137,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
|
||||
return "", "", err
|
||||
}
|
||||
if !ValidateDomainPart(domain) {
|
||||
return "", "", fmt.Errorf("Domain part validation failed")
|
||||
return "", "", errors.New("domain part validation failed")
|
||||
}
|
||||
return local, domain, nil
|
||||
}
|
||||
@@ -122,13 +145,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,22 +202,67 @@ 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) {
|
||||
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.
|
||||
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||
if address == "" {
|
||||
return "", "", fmt.Errorf("empty address")
|
||||
return "", "", errors.New("empty address")
|
||||
}
|
||||
if len(address) > 320 {
|
||||
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||
return "", "", errors.New("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 "", "", errors.New("missing terminating ':' in route specification")
|
||||
}
|
||||
address = address[end+1:]
|
||||
if address == "" {
|
||||
return "", "", errors.New("address empty after removing route specification")
|
||||
}
|
||||
}
|
||||
|
||||
if address[0] == '.' {
|
||||
return "", "", fmt.Errorf("address cannot start with a period")
|
||||
return "", "", errors.New("address cannot start with a period")
|
||||
}
|
||||
|
||||
// Loop over address parsing out local part.
|
||||
buf := new(bytes.Buffer)
|
||||
prev := byte('.')
|
||||
@@ -207,7 +286,7 @@ LOOP:
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
|
||||
case strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0:
|
||||
// These specials can be used unquoted.
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
@@ -218,7 +297,7 @@ LOOP:
|
||||
// A single period is OK.
|
||||
if prev == '.' {
|
||||
// Sequence of periods is not permitted.
|
||||
return "", "", fmt.Errorf("Sequence of periods is not permitted")
|
||||
return "", "", errors.New("sequence of periods is not permitted")
|
||||
}
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
@@ -228,19 +307,20 @@ LOOP:
|
||||
case c == '\\':
|
||||
inCharQuote = true
|
||||
case c == '"':
|
||||
if inCharQuote {
|
||||
switch {
|
||||
case inCharQuote:
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
} else if inStringQuote {
|
||||
case inStringQuote:
|
||||
inStringQuote = false
|
||||
} else {
|
||||
default:
|
||||
if i == 0 {
|
||||
inStringQuote = true
|
||||
} else {
|
||||
return "", "", fmt.Errorf("Quoted string can only begin at start of address")
|
||||
return "", "", errors.New("quoted string can only begin at start of address")
|
||||
}
|
||||
}
|
||||
case c == '@':
|
||||
@@ -253,16 +333,16 @@ LOOP:
|
||||
} else {
|
||||
// End of local-part.
|
||||
if i > 128 {
|
||||
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
|
||||
return "", "", errors.New("local part must not exceed 128 characters")
|
||||
}
|
||||
if prev == '.' {
|
||||
return "", "", fmt.Errorf("Local part cannot end with a period")
|
||||
return "", "", errors.New("local part cannot end with a period")
|
||||
}
|
||||
domain = address[i+1:]
|
||||
break LOOP
|
||||
}
|
||||
case c > 127:
|
||||
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
|
||||
return "", "", errors.New("characters outside of US-ASCII range not permitted")
|
||||
default:
|
||||
if inCharQuote || inStringQuote {
|
||||
err = buf.WriteByte(c)
|
||||
@@ -271,16 +351,16 @@ LOOP:
|
||||
}
|
||||
inCharQuote = false
|
||||
} else {
|
||||
return "", "", fmt.Errorf("Character %q must be quoted", c)
|
||||
return "", "", fmt.Errorf("character %q must be quoted", c)
|
||||
}
|
||||
}
|
||||
prev = c
|
||||
}
|
||||
if inCharQuote {
|
||||
return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair")
|
||||
return "", "", errors.New("cannot end address with unterminated quoted-pair")
|
||||
}
|
||||
if inStringQuote {
|
||||
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
|
||||
return "", "", errors.New("cannot end address with unterminated string quote")
|
||||
}
|
||||
return buf.String(), domain, nil
|
||||
}
|
||||
@@ -291,7 +371,7 @@ LOOP:
|
||||
// quoted according to RFC3696.
|
||||
func parseMailboxName(localPart string) (result string, err error) {
|
||||
if localPart == "" {
|
||||
return "", fmt.Errorf("Mailbox name cannot be empty")
|
||||
return "", errors.New("mailbox name cannot be empty")
|
||||
}
|
||||
result = strings.ToLower(localPart)
|
||||
invalid := make([]byte, 0, 10)
|
||||
@@ -300,13 +380,13 @@ func parseMailboxName(localPart string) (result string, err error) {
|
||||
switch {
|
||||
case 'a' <= c && c <= 'z':
|
||||
case '0' <= c && c <= '9':
|
||||
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
|
||||
case strings.IndexByte("!#$%&'*+-=/?^_`.{|}~", c) >= 0:
|
||||
default:
|
||||
invalid = append(invalid, c)
|
||||
}
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
|
||||
return "", fmt.Errorf("mailbox name contained invalid character(s): %q", invalid)
|
||||
}
|
||||
if idx := strings.Index(result, "+"); idx > -1 {
|
||||
result = result[0:idx]
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
)
|
||||
|
||||
func TestShouldAcceptDomain(t *testing.T) {
|
||||
@@ -33,7 +33,6 @@ func TestShouldAcceptDomain(t *testing.T) {
|
||||
if got != tc.want {
|
||||
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
// Test with default reject.
|
||||
@@ -60,7 +59,6 @@ func TestShouldAcceptDomain(t *testing.T) {
|
||||
if got != tc.want {
|
||||
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -90,7 +88,6 @@ func TestShouldStoreDomain(t *testing.T) {
|
||||
if got != tc.want {
|
||||
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
// Test with storage disabled.
|
||||
@@ -117,7 +114,6 @@ func TestShouldStoreDomain(t *testing.T) {
|
||||
if got != tc.want {
|
||||
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -259,29 +255,76 @@ 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 {
|
||||
t.Errorf("Error while parsing with local naming %q: %v", tc.input, err)
|
||||
} else {
|
||||
if result != tc.local {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
|
||||
}
|
||||
} else if result != tc.local {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
|
||||
}
|
||||
if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil {
|
||||
t.Errorf("Error while parsing with full naming %q: %v", tc.input, err)
|
||||
} else {
|
||||
if result != tc.full {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
||||
}
|
||||
} else if result != tc.full {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
||||
}
|
||||
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
|
||||
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
|
||||
} else {
|
||||
if result != tc.domain {
|
||||
} else if result != tc.domain {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test special cases with domain addressing mode.
|
||||
func TestExtractDomainMailboxValid(t *testing.T) {
|
||||
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||
|
||||
tests := map[string]struct {
|
||||
input string // Input to test
|
||||
domain string // Expected output when mailbox naming = domain
|
||||
}{
|
||||
"ipv4": {
|
||||
input: "[127.0.0.1]",
|
||||
domain: "[127.0.0.1]",
|
||||
},
|
||||
"medium ipv6": {
|
||||
input: "[IPv6:2001:db8:aaaa:1::100]",
|
||||
domain: "[IPv6:2001:db8:aaaa:1::100]",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
|
||||
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
|
||||
} else if result != tc.domain {
|
||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +332,7 @@ 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 +347,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 +363,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 +411,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 +469,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 +483,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
20
pkg/policy/origin.go
Normal file
20
pkg/policy/origin.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
)
|
||||
|
||||
// Origin represents a potential email origin, allows policies for it to be queried.
|
||||
type Origin struct {
|
||||
mail.Address
|
||||
addrPolicy *Addressing
|
||||
// LocalPart is the part of the address before @, including +extension.
|
||||
LocalPart string
|
||||
// Domain is the part of the address after @.
|
||||
Domain string
|
||||
}
|
||||
|
||||
// ShouldAccept returns true if Inbucket should accept mail from this origin.
|
||||
func (o *Origin) ShouldAccept() bool {
|
||||
return o.addrPolicy.ShouldAcceptOriginDomain(o.Domain)
|
||||
}
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
)
|
||||
|
||||
// MailboxListV1 renders a list of messages in a mailbox
|
||||
@@ -26,7 +26,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
messages, err := ctx.Manager.GetMetadata(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||
return fmt.Errorf("failed to get messages for %v: %v", name, err)
|
||||
}
|
||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||
for i, msg := range messages {
|
||||
@@ -108,7 +108,7 @@ func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Contex
|
||||
dec := json.NewDecoder(req.Body)
|
||||
dm := model.JSONMessageHeaderV1{}
|
||||
if err := dec.Decode(&dm); err != nil {
|
||||
return fmt.Errorf("Failed to decode JSON: %v", err)
|
||||
return fmt.Errorf("failed to decode JSON: %v", err)
|
||||
}
|
||||
if dm.Seen {
|
||||
err = ctx.Manager.MarkSeen(name, id)
|
||||
|
||||
@@ -9,26 +9,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
const (
|
||||
// JSON map keys
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
fromKey = "from"
|
||||
toKey = "to"
|
||||
subjectKey = "subject"
|
||||
dateKey = "date"
|
||||
sizeKey = "size"
|
||||
headerKey = "header"
|
||||
bodyKey = "body"
|
||||
textKey = "text"
|
||||
htmlKey = "html"
|
||||
)
|
||||
|
||||
func TestRestMailboxList(t *testing.T) {
|
||||
// Setup
|
||||
mm := test.NewManager()
|
||||
@@ -67,7 +53,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 +61,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 +69,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 +164,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"},
|
||||
@@ -200,6 +186,10 @@ func TestRestMessage(t *testing.T) {
|
||||
FileName: "favicon.png",
|
||||
ContentType: "image/png",
|
||||
}},
|
||||
Inlines: []*enmime.Part{{
|
||||
FileName: "statement.pdf",
|
||||
ContentType: "application/pdf",
|
||||
}},
|
||||
},
|
||||
)
|
||||
mm.AddMessage("good", msg1)
|
||||
@@ -235,10 +225,14 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
||||
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[0]/filename", "statement.pdf")
|
||||
decodedStringEquals(t, result, "attachments/[0]/content-type", "application/pdf")
|
||||
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
|
||||
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
|
||||
decodedStringEquals(t, result, "attachments/[1]/filename", "favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[1]/content-type", "image/png")
|
||||
decodedStringEquals(t, result, "attachments/[1]/download-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
|
||||
decodedStringEquals(t, result, "attachments/[1]/view-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -254,7 +248,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 +256,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 +264,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
|
||||
|
||||
@@ -3,12 +3,12 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||
)
|
||||
|
||||
// Client accesses the Inbucket REST API v1
|
||||
@@ -18,15 +18,22 @@ type Client struct {
|
||||
|
||||
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
|
||||
// "http://localhost:9000"
|
||||
func New(baseURL string) (*Client, error) {
|
||||
func New(baseURL string, opts ...Option) (*Client, error) {
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mergedOpts := getDefaultOptions()
|
||||
for _, opt := range opts {
|
||||
opt.apply(mergedOpts)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
restClient{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: mergedOpts.timeout,
|
||||
Transport: mergedOpts.transport,
|
||||
},
|
||||
baseURL: parsedURL,
|
||||
},
|
||||
@@ -35,33 +42,56 @@ func New(baseURL string) (*Client, error) {
|
||||
}
|
||||
|
||||
// ListMailbox returns a list of messages for the requested mailbox
|
||||
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
|
||||
func (c *Client) ListMailbox(name string) ([]*MessageHeader, error) {
|
||||
return c.ListMailboxWithContext(context.Background(), name)
|
||||
}
|
||||
|
||||
// ListMailboxWithContext returns a list of messages for the requested mailbox
|
||||
func (c *Client) ListMailboxWithContext(ctx context.Context, name string) ([]*MessageHeader, error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||
err = c.doJSON("GET", uri, &headers)
|
||||
headers := make([]*MessageHeader, 0, 32)
|
||||
|
||||
err := c.doJSON(ctx, "GET", uri, &headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add Client ref to each MessageHeader for convenience funcs.
|
||||
for _, h := range headers {
|
||||
h.client = c
|
||||
}
|
||||
return
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// GetMessage returns the message details given a mailbox name and message ID.
|
||||
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
|
||||
return c.GetMessageWithContext(context.Background(), name, id)
|
||||
}
|
||||
|
||||
// GetMessageWithContext returns the message details given a mailbox name and message ID.
|
||||
func (c *Client) GetMessageWithContext(ctx context.Context, name, id string) (*Message, error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||
err = c.doJSON("GET", uri, &message)
|
||||
var message Message
|
||||
|
||||
err := c.doJSON(ctx, "GET", uri, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message.client = c
|
||||
return
|
||||
return &message, nil
|
||||
}
|
||||
|
||||
// MarkSeen marks the specified message as having been read.
|
||||
func (c *Client) MarkSeen(name, id string) error {
|
||||
return c.MarkSeenWithContext(context.Background(), name, id)
|
||||
}
|
||||
|
||||
// MarkSeenWithContext marks the specified message as having been read.
|
||||
func (c *Client) MarkSeenWithContext(ctx context.Context, name, id string) error {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||
err := c.doJSON("PATCH", uri, nil)
|
||||
err := c.doJSON(ctx, "PATCH", uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,19 +100,25 @@ func (c *Client) MarkSeen(name, id string) error {
|
||||
|
||||
// GetMessageSource returns the message source given a mailbox name and message ID.
|
||||
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
||||
return c.GetMessageSourceWithContext(context.Background(), name, id)
|
||||
}
|
||||
|
||||
// GetMessageSourceWithContext returns the message source given a mailbox name and message ID.
|
||||
func (c *Client) GetMessageSourceWithContext(ctx context.Context, name, id string) (*bytes.Buffer, error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
|
||||
resp, err := c.do("GET", uri, nil)
|
||||
resp, err := c.do(ctx, "GET", uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil,
|
||||
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(resp.Body)
|
||||
return buf, err
|
||||
@@ -90,29 +126,43 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
||||
|
||||
// DeleteMessage deletes a single message given the mailbox name and message ID.
|
||||
func (c *Client) DeleteMessage(name, id string) error {
|
||||
return c.DeleteMessageWithContext(context.Background(), name, id)
|
||||
}
|
||||
|
||||
// DeleteMessageWithContext deletes a single message given the mailbox name and message ID.
|
||||
func (c *Client) DeleteMessageWithContext(ctx context.Context, name, id string) error {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||
resp, err := c.do("DELETE", uri, nil)
|
||||
resp, err := c.do(ctx, "DELETE", uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeMailbox deletes all messages in the given mailbox
|
||||
func (c *Client) PurgeMailbox(name string) error {
|
||||
return c.PurgeMailboxWithContext(context.Background(), name)
|
||||
}
|
||||
|
||||
// PurgeMailboxWithContext deletes all messages in the given mailbox
|
||||
func (c *Client) PurgeMailboxWithContext(ctx context.Context, name string) error {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||
resp, err := c.do("DELETE", uri, nil)
|
||||
resp, err := c.do(ctx, "DELETE", uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -124,17 +174,32 @@ type MessageHeader struct {
|
||||
|
||||
// GetMessage returns this message with content
|
||||
func (h *MessageHeader) GetMessage() (message *Message, err error) {
|
||||
return h.client.GetMessage(h.Mailbox, h.ID)
|
||||
return h.GetMessageWithContext(context.Background())
|
||||
}
|
||||
|
||||
// GetMessageWithContext returns this message with content
|
||||
func (h *MessageHeader) GetMessageWithContext(ctx context.Context) (message *Message, err error) {
|
||||
return h.client.GetMessageWithContext(ctx, h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// GetSource returns the source for this message
|
||||
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
|
||||
return h.client.GetMessageSource(h.Mailbox, h.ID)
|
||||
return h.GetSourceWithContext(context.Background())
|
||||
}
|
||||
|
||||
// GetSourceWithContext returns the source for this message
|
||||
func (h *MessageHeader) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
|
||||
return h.client.GetMessageSourceWithContext(ctx, h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// Delete deletes this message from the mailbox
|
||||
func (h *MessageHeader) Delete() error {
|
||||
return h.client.DeleteMessage(h.Mailbox, h.ID)
|
||||
return h.DeleteWithContext(context.Background())
|
||||
}
|
||||
|
||||
// DeleteWithContext deletes this message from the mailbox
|
||||
func (h *MessageHeader) DeleteWithContext(ctx context.Context) error {
|
||||
return h.client.DeleteMessageWithContext(ctx, h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// Message represents an Inbucket message including content
|
||||
@@ -145,10 +210,20 @@ type Message struct {
|
||||
|
||||
// GetSource returns the source for this message
|
||||
func (m *Message) GetSource() (*bytes.Buffer, error) {
|
||||
return m.client.GetMessageSource(m.Mailbox, m.ID)
|
||||
return m.GetSourceWithContext(context.Background())
|
||||
}
|
||||
|
||||
// GetSourceWithContext returns the source for this message
|
||||
func (m *Message) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
|
||||
return m.client.GetMessageSourceWithContext(ctx, m.Mailbox, m.ID)
|
||||
}
|
||||
|
||||
// Delete deletes this message from the mailbox
|
||||
func (m *Message) Delete() error {
|
||||
return m.client.DeleteMessage(m.Mailbox, m.ID)
|
||||
return m.DeleteWithContext(context.Background())
|
||||
}
|
||||
|
||||
// DeleteWithContext deletes this message from the mailbox
|
||||
func (m *Message) DeleteWithContext(ctx context.Context) error {
|
||||
return m.client.DeleteMessageWithContext(ctx, m.Mailbox, m.ID)
|
||||
}
|
||||
|
||||
39
pkg/rest/client/apiv1_client_opts.go
Normal file
39
pkg/rest/client/apiv1_client_opts.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// options is a struct that holds the options for the rest client
|
||||
type options struct {
|
||||
transport http.RoundTripper
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Option can apply itself to the private options type.
|
||||
type Option interface {
|
||||
apply(opts *options)
|
||||
}
|
||||
|
||||
func getDefaultOptions() *options {
|
||||
return &options{
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type transportOption struct {
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t transportOption) apply(opts *options) {
|
||||
opts.transport = t.transport
|
||||
}
|
||||
|
||||
// WithTransport sets the transport for the rest client.
|
||||
// Transport specifies the mechanism by which individual
|
||||
// HTTP requests are made.
|
||||
// If nil, http.DefaultTransport is used.
|
||||
func WithTransport(transport http.RoundTripper) Option {
|
||||
return transportOption{transport}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
)
|
||||
|
||||
func TestClientV1ListMailbox(t *testing.T) {
|
||||
@@ -209,6 +212,33 @@ func TestClientV1GetMessageSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1WithCustomTransport(t *testing.T) {
|
||||
// Call setup, passing a custom roundtripper and make sure it was used during the request.
|
||||
mockRoundTripper := &mockRoundTripper{ResponseBody: "Custom Transport"}
|
||||
c, router, teardown := setup(client.WithTransport(mockRoundTripper))
|
||||
|
||||
defer teardown()
|
||||
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
|
||||
Handler(&jsonHandler{json: `message source`})
|
||||
|
||||
// Method under test.
|
||||
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := mockRoundTripper.ResponseBody
|
||||
got := source.String()
|
||||
if got != want {
|
||||
t.Errorf("Source got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if mockRoundTripper.CallCount != 1 {
|
||||
t.Errorf("RoundTripper called %v times, want 1", mockRoundTripper.CallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1DeleteMessage(t *testing.T) {
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
@@ -336,11 +366,24 @@ func TestClientV1MessageHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockRoundTripper struct {
|
||||
ResponseBody string
|
||||
CallCount int
|
||||
}
|
||||
|
||||
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
m.CallCount++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(m.ResponseBody)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// setup returns a client, router and server for API testing.
|
||||
func setup() (c *client.Client, router *mux.Router, teardown func()) {
|
||||
func setup(opts ...client.Option) (c *client.Client, router *mux.Router, teardown func()) {
|
||||
router = mux.NewRouter()
|
||||
server := httptest.NewServer(router)
|
||||
c, err := client.New(server.URL)
|
||||
c, err := client.New(server.URL, opts...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -357,5 +400,5 @@ type jsonHandler struct {
|
||||
|
||||
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
j.called = true
|
||||
w.Write([]byte(j.json))
|
||||
_, _ = w.Write([]byte(j.json))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
)
|
||||
|
||||
// Example demonstrates basic usage for the Inbucket REST client.
|
||||
@@ -16,34 +16,42 @@ func Example() {
|
||||
baseURL, teardown := exampleSetup()
|
||||
defer teardown()
|
||||
|
||||
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
|
||||
// `localhost:9000`.
|
||||
restClient, err := client.New(baseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err := func() error {
|
||||
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
|
||||
// `localhost:9000`.
|
||||
restClient, err := client.New(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get a slice of message headers for the mailbox named `user1`.
|
||||
headers, err := restClient.ListMailbox("user1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, header := range headers {
|
||||
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
|
||||
}
|
||||
// Get a slice of message headers for the mailbox named `user1`.
|
||||
headers, err := restClient.ListMailbox("user1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, header := range headers {
|
||||
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
|
||||
}
|
||||
|
||||
// Get the content of the first message.
|
||||
message, err := headers[0].GetMessage()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("\nFrom: %v\n", message.From)
|
||||
fmt.Printf("Text body:\n%v", message.Body.Text)
|
||||
// Get the content of the first message.
|
||||
message, err := headers[0].GetMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\nFrom: %v\n", message.From)
|
||||
fmt.Printf("Text body:\n%v", message.Body.Text)
|
||||
|
||||
// Delete the second message.
|
||||
err = headers[1].Delete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
// Delete the second message.
|
||||
err = headers[1].Delete()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Print(err)
|
||||
}
|
||||
|
||||
// Output:
|
||||
@@ -62,7 +70,7 @@ func exampleSetup() (baseURL string, teardown func()) {
|
||||
|
||||
// Handle ListMailbox request.
|
||||
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`[
|
||||
_, _ = w.Write([]byte(`[
|
||||
{
|
||||
"mailbox": "user1",
|
||||
"id": "20180107T224128-0000",
|
||||
@@ -79,7 +87,7 @@ func exampleSetup() (baseURL string, teardown func()) {
|
||||
// Handle GetMessage request.
|
||||
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{
|
||||
_, _ = w.Write([]byte(`{
|
||||
"mailbox": "user1",
|
||||
"id": "20180107T224128-0000",
|
||||
"from": "admin@inbucket.org",
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
|
||||
// httpClient allows http.Client to be mocked for tests
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Generic REST restClient
|
||||
@@ -21,47 +22,24 @@ type restClient struct {
|
||||
}
|
||||
|
||||
// do performs an HTTP request with this client and returns the response.
|
||||
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
|
||||
rel, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := c.baseURL.ResolveReference(rel)
|
||||
func (c *restClient) do(ctx context.Context, method, uri string, body []byte) (*http.Response, error) {
|
||||
url := c.baseURL.JoinPath(uri)
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url.String(), r)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url.String(), r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
|
||||
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||
resp, err := c.do(method, uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
// Decode response body
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
||||
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
|
||||
resp, err := c.do(method, uri, body)
|
||||
func (c *restClient) doJSON(ctx context.Context, method string, uri string, v interface{}) error {
|
||||
resp, err := c.do(ctx, method, uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,22 +2,33 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const baseURLStr = "http://test.local:8080"
|
||||
const baseURLPathStr = "http://test.local:8080/inbucket"
|
||||
|
||||
var baseURL *url.URL
|
||||
|
||||
var baseURLPath *url.URL
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
baseURL, err = url.Parse(baseURLStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
baseURLPath, err = url.Parse(baseURLPathStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockHTTPClient struct {
|
||||
@@ -33,7 +44,7 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error)
|
||||
}
|
||||
resp = &http.Response{
|
||||
StatusCode: m.statusCode,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(m.body)),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -43,7 +54,7 @@ func (m *mockHTTPClient) ReqBody() []byte {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
body, err := ioutil.ReadAll(r)
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -51,32 +62,42 @@ func (m *mockHTTPClient) ReqBody() []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestDo(t *testing.T) {
|
||||
var want, got string
|
||||
mth := &mockHTTPClient{}
|
||||
c := &restClient{mth, baseURL}
|
||||
body := []byte("Test body")
|
||||
|
||||
_, err := c.do("POST", "/dopost", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestDoTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
uri string
|
||||
wantMethod string
|
||||
base *url.URL
|
||||
wantURL string
|
||||
wantBody []byte
|
||||
}{
|
||||
{method: "GET", wantMethod: "GET", uri: "/doget", base: baseURL, wantURL: baseURLStr + "/doget", wantBody: []byte("Test body 1")},
|
||||
{method: "POST", wantMethod: "POST", uri: "/dopost", base: baseURL, wantURL: baseURLStr + "/dopost", wantBody: []byte("Test body 2")},
|
||||
{method: "GET", wantMethod: "GET", uri: "/doget", base: baseURLPath, wantURL: baseURLPathStr + "/doget", wantBody: []byte("Test body 3")},
|
||||
{method: "POST", wantMethod: "POST", uri: "/dopost", base: baseURLPath, wantURL: baseURLPathStr + "/dopost", wantBody: []byte("Test body 4")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
testname := fmt.Sprintf("%s,%s", test.method, test.wantURL)
|
||||
t.Run(testname, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mth := &mockHTTPClient{}
|
||||
c := &restClient{mth, test.base}
|
||||
|
||||
want = "POST"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
resp, err := c.do(ctx, test.method, test.uri, test.wantBody)
|
||||
require.NoError(t, err)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
want = baseURLStr + "/dopost"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
b := mth.ReqBody()
|
||||
if !bytes.Equal(b, body) {
|
||||
t.Errorf("req.Body == %q, want %q", b, body)
|
||||
if mth.req.Method != test.wantMethod {
|
||||
t.Errorf("req.Method == %q, want %q", mth.req.Method, test.wantMethod)
|
||||
}
|
||||
if mth.req.URL.String() != test.wantURL {
|
||||
t.Errorf("req.URL == %q, want %q", mth.req.URL.String(), test.wantURL)
|
||||
}
|
||||
if !bytes.Equal(mth.ReqBody(), test.wantBody) {
|
||||
t.Errorf("req.Body == %q, want %q", mth.ReqBody(), test.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +110,7 @@ func TestDoJSON(t *testing.T) {
|
||||
c := &restClient{mth, baseURL}
|
||||
|
||||
var v map[string]interface{}
|
||||
err := c.doJSON("GET", "/doget", &v)
|
||||
err := c.doJSON(context.Background(), "GET", "/doget", &v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -123,7 +144,7 @@ func TestDoJSONNilV(t *testing.T) {
|
||||
mth := &mockHTTPClient{}
|
||||
c := &restClient{mth, baseURL}
|
||||
|
||||
err := c.doJSON("GET", "/doget", nil)
|
||||
err := c.doJSON(context.Background(), "GET", "/doget", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package rest
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
)
|
||||
|
||||
// SetupRoutes populates the routes for the REST interface
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
@@ -22,4 +24,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,85 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/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)
|
||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to setup read deadline")
|
||||
}
|
||||
conn.SetPongHandler(func(string) error {
|
||||
slog.Debug().Msg("Got pong")
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -92,8 +106,11 @@ 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) {
|
||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||
|
||||
ticker := time.NewTicker(pingPeriodV1)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
ml.Close()
|
||||
@@ -103,41 +120,34 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-ml.c:
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
|
||||
}
|
||||
if !ok {
|
||||
// msgListener closed, exit
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
_ = 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))
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
|
||||
}
|
||||
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")
|
||||
slog.Debug().Msg("Sent ping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close removes the listener registration
|
||||
func (ml *msgListener) Close() {
|
||||
func (ml *msgListenerV1) Close() {
|
||||
select {
|
||||
case <-ml.c:
|
||||
// Already closed
|
||||
@@ -152,7 +162,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 +174,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 +189,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 +201,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,
|
||||
}
|
||||
}
|
||||
|
||||
225
pkg/rest/socketv2_controller.go
Normal file
225
pkg/rest/socketv2_controller.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/v3/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)
|
||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to setup read deadline")
|
||||
}
|
||||
conn.SetPongHandler(func(string) error {
|
||||
slog.Debug().Msg("Got pong")
|
||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
|
||||
}
|
||||
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) {
|
||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||
|
||||
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:
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
|
||||
}
|
||||
if !ok {
|
||||
// msgListener closed, exit
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if conn.WriteJSON(event) != nil {
|
||||
// Write failed
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send ping
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
|
||||
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
|
||||
}
|
||||
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||
// Write error
|
||||
return
|
||||
}
|
||||
slog.Debug().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
|
||||
}
|
||||
@@ -2,38 +2,52 @@ package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
)
|
||||
|
||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req.Header.Add("Accept", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pass request to handlers directly.
|
||||
w := httptest.NewRecorder()
|
||||
web.Router.ServeHTTP(w, req)
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
|
||||
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(body))
|
||||
req.Header.Add("Accept", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pass request to handlers directly.
|
||||
w := httptest.NewRecorder()
|
||||
web.Router.ServeHTTP(w, req)
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -48,9 +62,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
|
||||
}
|
||||
@@ -111,12 +124,11 @@ func decodedStringEquals(t *testing.T, json interface{}, path string, want strin
|
||||
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
|
||||
// brackets require the parent element to be a []interface{}.
|
||||
//
|
||||
// getDecodedPath(o, "users", "[1]", "name")
|
||||
// getDecodedPath(o, "users", "[1]", "name")
|
||||
//
|
||||
// is equivalent to the JavaScript:
|
||||
//
|
||||
// o.users[1].name
|
||||
//
|
||||
// o.users[1].name
|
||||
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
|
||||
if len(path) == 0 {
|
||||
return o, ""
|
||||
@@ -124,9 +136,10 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
|
||||
if o == nil {
|
||||
return nil, " is nil"
|
||||
}
|
||||
key := path[0]
|
||||
present := false
|
||||
|
||||
var present bool
|
||||
var val interface{}
|
||||
key := path[0]
|
||||
if key[0] == '[' {
|
||||
// Expecting slice.
|
||||
index, err := strconv.Atoi(strings.Trim(key, "[]"))
|
||||
@@ -149,12 +162,15 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
|
||||
}
|
||||
val, present = omap[key]
|
||||
}
|
||||
|
||||
if !present {
|
||||
return nil, "/" + key + " is missing"
|
||||
}
|
||||
|
||||
result, msg := getDecodedPath(val, path[1:]...)
|
||||
if msg != "" {
|
||||
return nil, "/" + key + msg
|
||||
}
|
||||
|
||||
return result, ""
|
||||
}
|
||||
|
||||
131
pkg/server/lifecycle.go
Normal file
131
pkg/server/lifecycle.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/pop3"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/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 && err != luahost.ErrNoScript {
|
||||
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, err := pop3.NewServer(conf.POP3, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
|
||||
|
||||
s := &Services{
|
||||
MsgHub: msgHub,
|
||||
RetentionScanner: retentionScanner,
|
||||
POP3Server: pop3Server,
|
||||
SMTPServer: smtpServer,
|
||||
WebServer: webServer,
|
||||
ExtHost: extHost,
|
||||
LuaHost: luaHost,
|
||||
ready: &sync.WaitGroup{},
|
||||
}
|
||||
s.setupNotify()
|
||||
|
||||
return s, 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 returns a merged channel of the error notification channels of all fallible services,
|
||||
// allowing the process to be shutdown if needed.
|
||||
func (s *Services) Notify() <-chan error {
|
||||
return s.notify
|
||||
}
|
||||
|
||||
// setupNotify merges the error notification channels of all fallible services.
|
||||
func (s *Services) setupNotify() {
|
||||
c := make(chan error, 1)
|
||||
s.notify = c
|
||||
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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// makeReadyFunc returns a function used to signal that a service is ready. The `Services.ready`
|
||||
// wait group can then be used to await all services being ready.
|
||||
func (s *Services) makeReadyFunc() func() {
|
||||
s.ready.Add(1)
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(s.ready.Done)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package pop3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -53,6 +54,7 @@ var commands = map[string]bool{
|
||||
"PASS": true,
|
||||
"APOP": true,
|
||||
"CAPA": true,
|
||||
"STLS": true,
|
||||
}
|
||||
|
||||
// Session defines an active POP3 session
|
||||
@@ -102,11 +104,24 @@ func (s *Session) String() string {
|
||||
func (s *Server) startSession(id int, conn net.Conn) {
|
||||
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
|
||||
Int("session", id).Logger()
|
||||
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
|
||||
connToClose := conn
|
||||
if s.config.ForceTLS {
|
||||
logger.Debug().Msg("Setting up TLS for ForceTLS")
|
||||
tlsConn := tls.Server(conn, s.tlsConfig)
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
logger.Info().Msg("Starting POP3 session")
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
logger.Debug().Msg("closing at end of session")
|
||||
// Closing the tlsConn hangs.
|
||||
if err := connToClose.Close(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Closing connection")
|
||||
}
|
||||
logger.Debug().Msg("End of session")
|
||||
s.wg.Done()
|
||||
}()
|
||||
|
||||
@@ -117,46 +132,48 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
// This is our command reading loop
|
||||
for ssn.state != QUIT && ssn.sendError == nil {
|
||||
line, err := ssn.readLine()
|
||||
ssn.logger.Debug().Msgf("read %s", line)
|
||||
if err == nil {
|
||||
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||
// Check against valid SMTP commands
|
||||
if cmd == "" {
|
||||
ssn.send("-ERR Speak up")
|
||||
continue
|
||||
cmd, arg := ssn.parseCmd(line)
|
||||
// Commands we handle in any state
|
||||
if cmd == "CAPA" {
|
||||
// List our capabilities per RFC2449
|
||||
ssn.send("+OK Capability list follows")
|
||||
ssn.send("TOP")
|
||||
ssn.send("USER")
|
||||
ssn.send("UIDL")
|
||||
ssn.send("IMPLEMENTATION Inbucket")
|
||||
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
|
||||
ssn.send("STLS")
|
||||
}
|
||||
if !commands[cmd] {
|
||||
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
|
||||
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Commands we handle in any state
|
||||
switch cmd {
|
||||
case "CAPA":
|
||||
// List our capabilities per RFC2449
|
||||
ssn.send("+OK Capability list follows")
|
||||
ssn.send("TOP")
|
||||
ssn.send("USER")
|
||||
ssn.send("UIDL")
|
||||
ssn.send("IMPLEMENTATION Inbucket")
|
||||
ssn.send(".")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send command to handler for current state
|
||||
switch ssn.state {
|
||||
case AUTHORIZATION:
|
||||
ssn.authorizationHandler(cmd, arg)
|
||||
continue
|
||||
case TRANSACTION:
|
||||
ssn.transactionHandler(cmd, arg)
|
||||
continue
|
||||
}
|
||||
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||
break
|
||||
} else {
|
||||
ssn.send("-ERR Syntax error, command garbled")
|
||||
ssn.send(".")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check against valid SMTP commands
|
||||
if cmd == "" {
|
||||
ssn.send("-ERR Speak up")
|
||||
continue
|
||||
}
|
||||
|
||||
if !commands[cmd] {
|
||||
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
|
||||
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send command to handler for current state
|
||||
switch ssn.state {
|
||||
case AUTHORIZATION:
|
||||
ssn.authorizationHandler(cmd, arg)
|
||||
continue
|
||||
case TRANSACTION:
|
||||
ssn.transactionHandler(cmd, arg)
|
||||
continue
|
||||
}
|
||||
|
||||
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||
break
|
||||
} else {
|
||||
// readLine() returned an error
|
||||
if err == io.EOF {
|
||||
@@ -193,7 +210,37 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
|
||||
switch cmd {
|
||||
case "QUIT":
|
||||
s.send("+OK Goodnight and good luck")
|
||||
s.logger.Debug().Msg("Quitting.")
|
||||
s.enterState(QUIT)
|
||||
|
||||
case "STLS":
|
||||
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
|
||||
// Invalid command since TLS unconfigured.
|
||||
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
|
||||
s.send("-ERR TLS unavailable on the server")
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
if s.tlsState != nil {
|
||||
// TLS state previously valid.
|
||||
s.logger.Debug().Msg("-ERR A TLS session already agreed upon.")
|
||||
s.send("-ERR A TLS session already agreed upon.")
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
s.logger.Debug().Msg("Initiating TLS context.")
|
||||
|
||||
// Start TLS connection handshake.
|
||||
s.send("+OK Begin TLS Negotiation")
|
||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
s.conn = tlsConn
|
||||
s.reader = bufio.NewReader(tlsConn)
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
s.logger.Debug().Msgf("TLS set %v", *s.tlsState)
|
||||
|
||||
case "USER":
|
||||
if len(args) > 0 {
|
||||
s.user = args[0]
|
||||
@@ -414,7 +461,7 @@ func (s *Session) transactionHandler(cmd string, args []string) {
|
||||
s.processDeletes()
|
||||
s.enterState(QUIT)
|
||||
case "NOOP":
|
||||
s.send("+OK I have sucessfully done nothing")
|
||||
s.send("+OK I have successfully done nothing")
|
||||
case "RSET":
|
||||
// Reset session, don't actually delete anything I told you to
|
||||
s.logger.Debug().Msgf("Resetting session state on RSET request")
|
||||
@@ -582,14 +629,14 @@ func (s *Session) readLine() (line string, err error) {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
|
||||
func (s *Session) parseCmd(line string) (cmd string, args []string) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
return "", nil, true
|
||||
return "", nil
|
||||
}
|
||||
|
||||
words := strings.Split(line, " ")
|
||||
return strings.ToUpper(words[0]), words[1:], true
|
||||
return strings.ToUpper(words[0]), words[1:]
|
||||
}
|
||||
|
||||
func (s *Session) reset() {
|
||||
|
||||
305
pkg/server/pop3/handler_test.go
Normal file
305
pkg/server/pop3/handler_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
)
|
||||
|
||||
func TestNoTLS(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server := setupPOPServer(t, ds, false, false)
|
||||
pipe := setupPOPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
defer func() {
|
||||
_ = c.PrintfLine("QUIT")
|
||||
_, _ = c.ReadLine()
|
||||
server.Drain()
|
||||
}()
|
||||
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading initial line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("Initial line is not +OK")
|
||||
}
|
||||
if err := c.PrintfLine("CAPA"); err != nil {
|
||||
t.Fatalf("Failed to send CAPA; %v.", err)
|
||||
}
|
||||
replies := []string{}
|
||||
for {
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA line failed %v", err)
|
||||
}
|
||||
if reply == "." {
|
||||
break
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
|
||||
for _, r := range replies {
|
||||
if r == "STLS" {
|
||||
t.Errorf("TLS not enabled but received STLS.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTLS(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server := setupPOPServer(t, ds, true, false)
|
||||
pipe := setupPOPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
defer func() {
|
||||
_ = c.PrintfLine("QUIT")
|
||||
_, _ = c.ReadLine()
|
||||
server.Drain()
|
||||
}()
|
||||
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading initial line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("Initial line is not +OK")
|
||||
}
|
||||
if err := c.PrintfLine("CAPA"); err != nil {
|
||||
t.Fatalf("Failed to send CAPA; %v.", err)
|
||||
}
|
||||
replies := []string{}
|
||||
for {
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA line failed %v", err)
|
||||
}
|
||||
if reply == "." {
|
||||
break
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
|
||||
sawTLS := false
|
||||
for _, r := range replies {
|
||||
if r == "STLS" {
|
||||
sawTLS = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sawTLS {
|
||||
t.Errorf("TLS enabled but no STLS capability.")
|
||||
}
|
||||
|
||||
if err := c.PrintfLine("STLS"); err != nil {
|
||||
t.Fatalf("Failed to send STLS; %v.", err)
|
||||
}
|
||||
reply, err = c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading STLS reply line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("STLS failed: %s", reply)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
tlsConn := tls.Client(pipe, tlsConfig)
|
||||
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer toCancel()
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
t.Fatalf("TLS handshake failed; %v", err)
|
||||
}
|
||||
c = textproto.NewConn(tlsConn)
|
||||
if err := c.PrintfLine("CAPA"); err != nil {
|
||||
t.Fatalf("Failed to send CAPA; %v.", err)
|
||||
}
|
||||
reply, err = c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA reply line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("CAPA failed: %s", reply)
|
||||
}
|
||||
for {
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA line failed %v", err)
|
||||
}
|
||||
if reply == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceTLS(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server := setupPOPServer(t, ds, true, true)
|
||||
pipe := setupPOPSession(t, server)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
tlsConn := tls.Client(pipe, tlsConfig)
|
||||
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer toCancel()
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
t.Fatalf("TLS handshake failed; %v", err)
|
||||
}
|
||||
c := textproto.NewConn(tlsConn)
|
||||
defer func() {
|
||||
_ = c.PrintfLine("QUIT")
|
||||
_, _ = c.ReadLine()
|
||||
server.Drain()
|
||||
}()
|
||||
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading initial line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("Initial line is not +OK")
|
||||
}
|
||||
|
||||
if err := c.PrintfLine("CAPA"); err != nil {
|
||||
t.Fatalf("Failed to send CAPA; %v.", err)
|
||||
}
|
||||
reply, err = c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA reply line failed %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "+OK") {
|
||||
t.Fatalf("CAPA failed: %s", reply)
|
||||
}
|
||||
for {
|
||||
reply, err := c.ReadLine()
|
||||
if err != nil {
|
||||
t.Fatalf("Reading CAPA line failed %v", err)
|
||||
}
|
||||
if reply == "STLS" {
|
||||
t.Errorf("STLS in CAPA in forceTLS mode.")
|
||||
}
|
||||
if reply == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// net.Pipe does not implement deadlines
|
||||
type mockConn struct {
|
||||
net.Conn
|
||||
}
|
||||
|
||||
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 setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Server {
|
||||
t.Helper()
|
||||
cfg := config.POP3{
|
||||
Addr: "127.0.0.1:2500",
|
||||
Domain: "inbucket.local",
|
||||
Timeout: 5,
|
||||
Debug: true,
|
||||
ForceTLS: forceTLS,
|
||||
}
|
||||
if tls {
|
||||
cert, privKey, err := generateCertificate(t)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate x.509 certificate; %v", err)
|
||||
}
|
||||
// we have to write these things into files.
|
||||
|
||||
cfg.TLSEnabled = true
|
||||
td := t.TempDir()
|
||||
certPath := path.Join(td, "cert.pem")
|
||||
keyPath := path.Join(td, "key.pem")
|
||||
if err := os.WriteFile(certPath, certToPem(cert), 0700); err != nil {
|
||||
t.Fatalf("Failed to write cert PEM file; %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, privKeyToPem(privKey), 0700); err != nil {
|
||||
t.Fatalf("Failed to write privKey PEM file; %v", err)
|
||||
}
|
||||
|
||||
cfg.TLSCert = certPath
|
||||
cfg.TLSPrivKey = keyPath
|
||||
}
|
||||
|
||||
s, err := NewServer(cfg, ds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create server: %v.", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var sessionNum int
|
||||
|
||||
func setupPOPSession(t *testing.T, server *Server) net.Conn {
|
||||
t.Helper()
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
// Start the session.
|
||||
server.wg.Add(1)
|
||||
sessionNum++
|
||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
||||
|
||||
return clientConn
|
||||
}
|
||||
|
||||
func privKeyToPem(privkey *rsa.PrivateKey) []byte {
|
||||
privkeyBytes := x509.MarshalPKCS1PrivateKey(privkey)
|
||||
return pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privkeyBytes,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func certToPem(cert []byte) []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})
|
||||
}
|
||||
|
||||
func generateCertificate(t *testing.T) ([]byte, *rsa.PrivateKey, error) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key; %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "localhost.local",
|
||||
},
|
||||
DNSNames: []string{"localhost", "127.0.0.1", "inbucket.local"},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageEmailProtection},
|
||||
}
|
||||
|
||||
cert, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("certificate generation failed; %v", err)
|
||||
}
|
||||
return cert, priv, nil
|
||||
}
|
||||
@@ -2,58 +2,82 @@ package pop3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// 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.
|
||||
tlsConfig *tls.Config // TLS encryption configuration.
|
||||
tlsState *tls.ConnectionState
|
||||
}
|
||||
|
||||
// New creates a new Server struct.
|
||||
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
|
||||
return &Server{
|
||||
config: pop3Config,
|
||||
store: store,
|
||||
globalShutdown: shutdownChan,
|
||||
wg: new(sync.WaitGroup),
|
||||
// NewServer creates a new, unstarted, POP3 server.
|
||||
func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
|
||||
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
|
||||
tlsConfig := &tls.Config{}
|
||||
if pop3Config.TLSEnabled {
|
||||
var err error
|
||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
|
||||
if err != nil {
|
||||
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||
return nil, fmt.Errorf("failed to configure TLS; %v", err)
|
||||
// Do not silently turn off Security.
|
||||
}
|
||||
slog.Debug().Msg("TLS config available")
|
||||
} else {
|
||||
tlsConfig = nil
|
||||
}
|
||||
return &Server{
|
||||
config: pop3Config,
|
||||
store: store,
|
||||
wg: new(sync.WaitGroup),
|
||||
notify: make(chan error, 1),
|
||||
tlsConfig: tlsConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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():
|
||||
}
|
||||
<-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")
|
||||
@@ -66,8 +90,8 @@ func (s *Server) serve(ctx context.Context) {
|
||||
var tempDelay time.Duration
|
||||
for sid := 1; ; sid++ {
|
||||
if conn, err := s.listener.Accept(); err != nil {
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||
// Temporary error, sleep for a bit and try again.
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
|
||||
// Timeout, sleep for a bit and try again.
|
||||
if tempDelay == 0 {
|
||||
tempDelay = 5 * time.Millisecond
|
||||
} else {
|
||||
@@ -77,7 +101,7 @@ func (s *Server) serve(ctx context.Context) {
|
||||
tempDelay = max
|
||||
}
|
||||
log.Error().Str("module", "pop3").Err(err).
|
||||
Msgf("POP3 accept error; retrying in %v", tempDelay)
|
||||
Msgf("POP3 accept timout; retrying in %v", tempDelay)
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
} else {
|
||||
@@ -88,7 +112,8 @@ func (s *Server) serve(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
// Something went wrong.
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -100,18 +125,15 @@ 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
|
||||
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("waiting for connections to complete.")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,18 +14,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our SMTP state machine.
|
||||
type State int
|
||||
|
||||
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
|
||||
|
||||
@@ -58,7 +56,7 @@ const (
|
||||
// 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 {
|
||||
@@ -106,7 +104,7 @@ type Session struct {
|
||||
sendError error // Last network send error.
|
||||
state State // Session state machine.
|
||||
reader *bufio.Reader // Buffered reading for TCP conn.
|
||||
from string // Sender from MAIL command.
|
||||
from *policy.Origin // Sender from MAIL command.
|
||||
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||
logger zerolog.Logger // Session specific logger.
|
||||
debug bool // Print network traffic to stdout.
|
||||
@@ -119,7 +117,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
|
||||
reader := bufio.NewReader(conn)
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
|
||||
return &Session{
|
||||
session := &Session{
|
||||
Server: server,
|
||||
id: id,
|
||||
conn: conn,
|
||||
@@ -131,26 +129,34 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
|
||||
debug: server.config.Debug,
|
||||
text: textproto.NewConn(conn),
|
||||
}
|
||||
if server.config.ForceTLS {
|
||||
session.tlsState = new(tls.ConnectionState)
|
||||
*session.tlsState = conn.(*tls.Conn).ConnectionState()
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *Session) String() string {
|
||||
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
|
||||
}
|
||||
|
||||
/* Session flow:
|
||||
* 1. Send initial greeting
|
||||
* 2. Receive cmd
|
||||
* 3. If good cmd, respond, optionally change state
|
||||
* 4. If bad cmd, respond error
|
||||
* 5. Goto 2
|
||||
*/
|
||||
func (s *Server) startSession(id int, conn net.Conn) {
|
||||
logger := log.Hook(logHook{}).With().
|
||||
// Session flow:
|
||||
// 1. Send initial greeting
|
||||
// 2. Receive cmd
|
||||
// 3. If good cmd, respond, optionally change state
|
||||
// 4. If bad cmd, respond error
|
||||
// 5. Goto 2
|
||||
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()
|
||||
logger.Info().Msg("Starting SMTP session")
|
||||
|
||||
// Update WaitGroup and counters.
|
||||
s.wg.Add(1)
|
||||
expConnectsCurrent.Add(1)
|
||||
expConnectsTotal.Add(1)
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Closing connection")
|
||||
@@ -171,13 +177,13 @@ 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
|
||||
// Handle LOGIN/PASSWORD states here, because they don't expect a command.
|
||||
switch ssn.state {
|
||||
case LOGIN:
|
||||
ssn.loginHandler(line)
|
||||
ssn.loginHandler()
|
||||
continue
|
||||
case PASSWORD:
|
||||
ssn.passwordHandler(line)
|
||||
ssn.passwordHandler()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -204,7 +210,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
ssn.send("252 Cannot VRFY user, but will accept message")
|
||||
continue
|
||||
case "NOOP":
|
||||
ssn.send("250 I have sucessfully done nothing")
|
||||
ssn.send("250 I have successfully done nothing")
|
||||
continue
|
||||
case "RSET":
|
||||
// Reset session
|
||||
@@ -289,7 +295,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
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 {
|
||||
if s.Server.config.TLSEnabled && !s.Server.config.ForceTLS && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||
s.send("250-STARTTLS")
|
||||
}
|
||||
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
||||
@@ -305,18 +311,18 @@ func parseHelloArgument(arg string) (string, error) {
|
||||
domain = arg[:idx]
|
||||
}
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("Invalid domain")
|
||||
return "", errors.New("invalid domain")
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func (s *Session) loginHandler(line string) {
|
||||
func (s *Session) loginHandler() {
|
||||
// Content and length of username is ignored.
|
||||
s.send(fmt.Sprintf("334 %v", passwordChallenge))
|
||||
s.enterState(PASSWORD)
|
||||
}
|
||||
|
||||
func (s *Session) passwordHandler(line string) {
|
||||
func (s *Session) passwordHandler() {
|
||||
// Content and length of password is ignored.
|
||||
s.send("235 Authentication successful")
|
||||
s.enterState(READY)
|
||||
@@ -325,7 +331,8 @@ func (s *Session) passwordHandler(line string) {
|
||||
// READY state -> waiting for MAIL
|
||||
// AUTH can change
|
||||
func (s *Session) readyHandler(cmd string, arg string) {
|
||||
if cmd == "STARTTLS" {
|
||||
switch cmd {
|
||||
case "STARTTLS":
|
||||
if !s.Server.config.TLSEnabled {
|
||||
// Invalid command since TLS unconfigured.
|
||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||
@@ -348,88 +355,125 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
s.tlsState = new(tls.ConnectionState)
|
||||
*s.tlsState = tlsConn.ConnectionState()
|
||||
s.enterState(GREET)
|
||||
} else if cmd == "AUTH" {
|
||||
|
||||
case "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")
|
||||
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
|
||||
}
|
||||
s.send(fmt.Sprintf("334 %v", usernameChallenge))
|
||||
s.enterState(LOGIN)
|
||||
return
|
||||
|
||||
default:
|
||||
{
|
||||
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if cmd == "MAIL" {
|
||||
// Capture group 1: from address. 2: optional params.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
if m == nil {
|
||||
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
|
||||
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||
s.send("501 Bad sender address syntax")
|
||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
}
|
||||
if from == "" {
|
||||
from = "unspecified"
|
||||
}
|
||||
|
||||
// This is where the client may put BODY=8BITMIME, but we already
|
||||
// read the DATA as bytes, so it does not effect our processing.
|
||||
if m[2] != "" {
|
||||
args, ok := s.parseArgs(m[2])
|
||||
if !ok {
|
||||
s.send("501 Unable to parse MAIL ESMTP parameters")
|
||||
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||
return
|
||||
}
|
||||
if args["SIZE"] != "" {
|
||||
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
|
||||
if err != nil {
|
||||
s.send("501 Unable to parse SIZE as an integer")
|
||||
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
|
||||
return
|
||||
}
|
||||
if int(size) > s.config.MaxMessageBytes {
|
||||
s.send("552 Max message size exceeded")
|
||||
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
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 if cmd == "EHLO" {
|
||||
case "MAIL":
|
||||
s.parseMailFromCmd(arg)
|
||||
|
||||
case "EHLO":
|
||||
// Reset session
|
||||
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||
s.reset()
|
||||
s.send("250 Session reset")
|
||||
} else {
|
||||
|
||||
default:
|
||||
s.ooSeq(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Parses `MAIL FROM` command.
|
||||
func (s *Session) parseMailFromCmd(arg string) {
|
||||
// Capture group 1: from address. 2: optional params.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
if m == nil {
|
||||
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
|
||||
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
s.logger.Debug().Msgf("Mail sender is %v", from)
|
||||
|
||||
// Parse from address.
|
||||
localpart, domain, err := policy.ParseEmailAddress(from)
|
||||
s.logger.Debug().Msgf("Origin domain is %v", domain)
|
||||
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
|
||||
}
|
||||
if from == "" {
|
||||
from = "unspecified"
|
||||
}
|
||||
|
||||
// Parse ESMTP parameters.
|
||||
if m[2] != "" {
|
||||
// Here the client may put BODY=8BITMIME, but Inbucket already
|
||||
// reads the DATA as bytes, so it does not effect mail processing.
|
||||
args, ok := s.parseArgs(m[2])
|
||||
if !ok {
|
||||
s.send("501 Unable to parse MAIL ESMTP parameters")
|
||||
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject oversized messages.
|
||||
if args["SIZE"] != "" {
|
||||
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
|
||||
if err != nil {
|
||||
s.send("501 Unable to parse SIZE as an integer")
|
||||
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
|
||||
return
|
||||
}
|
||||
if int(size) > s.config.MaxMessageBytes {
|
||||
s.send("552 Max message size exceeded")
|
||||
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process through extensions.
|
||||
extResult := s.extHost.Events.BeforeMailAccepted.Emit(
|
||||
&event.AddressParts{Local: localpart, Domain: domain})
|
||||
if extResult != nil && !*extResult {
|
||||
s.send("550 Mail denied by policy")
|
||||
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
|
||||
return
|
||||
}
|
||||
|
||||
// Sender was permitted by an extension, or no extension rejected it.
|
||||
origin, err := s.addrPolicy.ParseOrigin(from)
|
||||
if err != nil {
|
||||
s.send("501 Bad origin address syntax")
|
||||
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
|
||||
return
|
||||
}
|
||||
s.from = origin
|
||||
if !s.from.ShouldAccept() {
|
||||
s.send("501 Unauthorized domain")
|
||||
s.logger.Warn().Msgf("Bad domain sender %s", domain)
|
||||
return
|
||||
}
|
||||
|
||||
// Ok to transition to MAIL state.
|
||||
s.logger.Info().Msgf("Mail from: %v", from)
|
||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||
s.enterState(MAIL)
|
||||
}
|
||||
|
||||
// MAIL state -> waiting for RCPTs followed by DATA
|
||||
func (s *Session) mailHandler(cmd string, arg string) {
|
||||
switch cmd {
|
||||
@@ -499,31 +543,24 @@ func (s *Session) dataHandler() {
|
||||
}
|
||||
mailData := bytes.NewBuffer(msgBuf)
|
||||
|
||||
// Mail data complete.
|
||||
tstamp := time.Now().Format(timeStampFormat)
|
||||
for _, recip := range s.recipients {
|
||||
if recip.ShouldStore() {
|
||||
// Generate Received header.
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
// Generate Received header; Deliver() will append recipient and timestamp to this.
|
||||
recvdHeader := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain)
|
||||
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
expReceivedTotal.Add(1)
|
||||
// Deliver message.
|
||||
if err := s.manager.Deliver(s.from, s.recipients, recvdHeader, mailData.Bytes()); err != nil {
|
||||
// Deliver() logs failure details, and the effected mailbox.
|
||||
s.send("451 Failed to store message")
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO Consider changing this to just 1 regardless of # of recipents.
|
||||
expReceivedTotal.Add(int64(len(s.recipients)))
|
||||
|
||||
s.send("250 Mail accepted for delivery")
|
||||
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) enterState(state State) {
|
||||
@@ -588,6 +625,7 @@ 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")
|
||||
s.logger.Debug().Msgf("Line received: %v", line)
|
||||
|
||||
// Find length of command or entire line.
|
||||
hasArg := true
|
||||
@@ -615,11 +653,13 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
// 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)
|
||||
@@ -634,7 +674,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||
|
||||
func (s *Session) reset() {
|
||||
s.enterState(READY)
|
||||
s.from = ""
|
||||
s.from = nil
|
||||
s.recipients = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -24,14 +25,40 @@ 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) {
|
||||
script := []scriptStep{
|
||||
tc,
|
||||
{"QUIT", 221}}
|
||||
playSession(t, server, script)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
// Test out some mangled HELOs
|
||||
script := []scriptStep{
|
||||
tests := []scriptStep{
|
||||
{"HELO", 501},
|
||||
{"EHLO", 501},
|
||||
{"HELLO", 500},
|
||||
@@ -39,86 +66,40 @@ 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)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Valid EHLOs
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
||||
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 err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.send, func(t *testing.T) {
|
||||
script := []scriptStep{
|
||||
tc,
|
||||
{"QUIT", 221}}
|
||||
playSession(t, server, script)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
// Test out some empty envelope without blanks
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
{"MAIL FROM:<>", 501},
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test out some empty envelope with blanks
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM: <>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
{"MAIL FROM: <>", 501},
|
||||
}
|
||||
playSession(t, server, script)
|
||||
}
|
||||
|
||||
// Test AUTH
|
||||
// Test AUTH commands.
|
||||
func TestAuth(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
|
||||
// PLAIN AUTH
|
||||
script := []scriptStep{
|
||||
@@ -131,9 +112,7 @@ func TestAuth(t *testing.T) {
|
||||
{"RSET", 250},
|
||||
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// LOGIN AUTH
|
||||
script = []scriptStep{
|
||||
@@ -146,27 +125,85 @@ func TestAuth(t *testing.T) {
|
||||
{"", 334},
|
||||
{"", 235},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
playSession(t, server, script)
|
||||
}
|
||||
|
||||
// Test TLS commands.
|
||||
func TestTLS(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
|
||||
// Test Start TLS parsing.
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"STARTTLS", 454}, // TLS unconfigured.
|
||||
}
|
||||
|
||||
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)
|
||||
playSession(t, server, script)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
tc,
|
||||
{"QUIT", 221}}
|
||||
playSession(t, server, script)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestReadyState(t *testing.T) {
|
||||
// Test invalid domains in READY state.
|
||||
func TestReadyStateRejectedDomains(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
server := setupSMTPServer(ds, extension.NewHost())
|
||||
|
||||
// Test out some mangled READY commands
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
tests := []scriptStep{
|
||||
{"MAIL FROM: <john@validdomain.com>", 250},
|
||||
{"MAIL FROM: <john@invalidomain.com>", 501},
|
||||
{"MAIL FROM: <john@s1.otherinvaliddomain.com>", 501},
|
||||
{"MAIL FROM: <john@s2.otherinvaliddomain.com>", 501},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.send, func(t *testing.T) {
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
tc,
|
||||
{"QUIT", 221}}
|
||||
playSession(t, server, script)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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},
|
||||
@@ -178,63 +215,22 @@ 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)
|
||||
}
|
||||
|
||||
// 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:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<\"first last\"@space.com>", 250},
|
||||
{"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)
|
||||
}
|
||||
|
||||
// Test Start TLS parsing.
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"STARTTLS", 454}, // TLS unconfigured.
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// 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) {
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
tc,
|
||||
{"QUIT", 221}}
|
||||
playSession(t, server, script)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
// Test out some mangled READY commands
|
||||
script := []scriptStep{
|
||||
@@ -250,9 +246,7 @@ func TestMailState(t *testing.T) {
|
||||
{"RCPT TO:<first last@host.com>", 501},
|
||||
{"RCPT TO:<fred@fish@host.com", 501},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test out some good RCPT commands
|
||||
script = []scriptStep{
|
||||
@@ -266,10 +260,10 @@ 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)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test out recipient limit
|
||||
script = []scriptStep{
|
||||
@@ -282,9 +276,7 @@ func TestMailState(t *testing.T) {
|
||||
{"RCPT TO:<u5@gmail.com>", 250},
|
||||
{"RCPT TO:<u6@gmail.com>", 552},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test DATA
|
||||
script = []scriptStep{
|
||||
@@ -294,9 +286,7 @@ func TestMailState(t *testing.T) {
|
||||
{"DATA", 354},
|
||||
{".", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test late EHLO, similar to RSET
|
||||
script = []scriptStep{
|
||||
@@ -307,9 +297,7 @@ func TestMailState(t *testing.T) {
|
||||
{"EHLO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test RSET
|
||||
script = []scriptStep{
|
||||
@@ -319,9 +307,7 @@ func TestMailState(t *testing.T) {
|
||||
{"RSET", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
|
||||
// Test QUIT
|
||||
script = []scriptStep{
|
||||
@@ -330,26 +316,16 @@ func TestMailState(t *testing.T) {
|
||||
{"RCPT TO:<u1@gmail.com>", 250},
|
||||
{"QUIT", 221},
|
||||
}
|
||||
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)
|
||||
}
|
||||
playSession(t, server, script)
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
var script []scriptStep
|
||||
pipe := setupSMTPSession(server)
|
||||
pipe := setupSMTPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
@@ -361,9 +337,8 @@ func TestDataState(t *testing.T) {
|
||||
{"RCPT TO:<u1@gmail.com>", 250},
|
||||
{"DATA", 354},
|
||||
}
|
||||
if err := playScriptAgainst(t, c, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playScriptAgainst(t, c, script)
|
||||
|
||||
// Send a message
|
||||
body := `To: u1@gmail.com
|
||||
From: john@gmail.com
|
||||
@@ -377,9 +352,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)
|
||||
@@ -390,54 +367,128 @@ Hi!
|
||||
{"RCPT TO:<u1@gmail.com>", 250},
|
||||
{"DATA", 354},
|
||||
}
|
||||
if err := playScriptAgainst(t, c, script); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
playScriptAgainst(t, c, script)
|
||||
|
||||
// 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)
|
||||
}
|
||||
_, _ = c.Cmd("QUIT")
|
||||
_, _, _ = c.ReadCodeLine(221)
|
||||
}
|
||||
|
||||
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)
|
||||
// Tests "MAIL FROM" emits BeforeMailAccepted event.
|
||||
func TestBeforeMailAcceptedEventEmitted(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
extHost := extension.NewHost()
|
||||
server := setupSMTPServer(ds, extHost)
|
||||
|
||||
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}}
|
||||
playSession(t, server, script)
|
||||
|
||||
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)
|
||||
|
||||
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}}
|
||||
playSession(t, server, script)
|
||||
|
||||
assert.NotNil(t, gotEvent, "BeforeMailListener did not receive Address")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
func playSession(t *testing.T, server *Server, script []scriptStep) {
|
||||
t.Helper()
|
||||
pipe := setupSMTPSession(t, server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
return fmt.Errorf("Expected a 220 greeting, got %v", code)
|
||||
t.Errorf("expected a 220 greeting, got %v", code)
|
||||
}
|
||||
|
||||
err := playScriptAgainst(t, c, script)
|
||||
playScriptAgainst(t, c, script)
|
||||
|
||||
// Not all tests leave the session in a clean state, so the following two
|
||||
// calls can fail
|
||||
// Not all tests leave the session in a clean state, so the following two calls can fail
|
||||
_, _ = c.Cmd("QUIT")
|
||||
_, _, _ = c.ReadCodeLine(221)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// playScriptAgainst an existing connection, does not handle server greeting
|
||||
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) error {
|
||||
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) {
|
||||
t.Helper()
|
||||
|
||||
for i, step := range script {
|
||||
id, err := c.Cmd(step.send)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Step %d, failed to send %q: %v", i, step.send, err)
|
||||
t.Fatalf("Step %d, failed to send %q: %v", i, step.send, err)
|
||||
}
|
||||
|
||||
c.StartResponse(id)
|
||||
@@ -449,11 +500,10 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
|
||||
c.EndResponse(id)
|
||||
|
||||
if err != nil {
|
||||
// Return after c.EndResponse so we don't hang the connection
|
||||
return err
|
||||
// Fail after c.EndResponse so we don't hang the connection
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// net.Pipe does not implement deadlines
|
||||
@@ -465,41 +515,46 @@ 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()) {
|
||||
// Creates an unstarted smtp.Server.
|
||||
func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
|
||||
cfg := &config.Root{
|
||||
MailboxNaming: config.FullNaming,
|
||||
SMTP: config.SMTP{
|
||||
Addr: "127.0.0.1:2500",
|
||||
Domain: "inbucket.local",
|
||||
MaxRecipients: 5,
|
||||
MaxMessageBytes: 5000,
|
||||
DefaultAccept: true,
|
||||
RejectDomains: []string{"deny.com"},
|
||||
Timeout: 5,
|
||||
Addr: "127.0.0.1:2500",
|
||||
Domain: "inbucket.local",
|
||||
MaxRecipients: 5,
|
||||
MaxMessageBytes: 5000,
|
||||
DefaultAccept: true,
|
||||
RejectDomains: []string{"deny.com"},
|
||||
RejectOriginDomains: []string{"invalidomain.com", "*.otherinvaliddomain.com"},
|
||||
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)
|
||||
}
|
||||
|
||||
// Create a server, but don't start it.
|
||||
addrPolicy := &policy.Addressing{Config: cfg}
|
||||
manager := &message.StoreManager{Store: ds}
|
||||
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
|
||||
return s, buf, teardown
|
||||
manager := &message.StoreManager{Store: ds, ExtHost: extHost}
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
logger := zerolog.New(zerolog.NewTestWriter(t))
|
||||
serverConn, clientConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = clientConn.Close()
|
||||
|
||||
// Drain is required to prevent a test-logging data race. If a (failing) test run is
|
||||
// hanging, this may be the culprit.
|
||||
server.Drain()
|
||||
})
|
||||
|
||||
// Start the session.
|
||||
server.wg.Add(1)
|
||||
sessionNum++
|
||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
||||
go server.startSession(sessionNum, &mockConn{serverConn}, logger)
|
||||
|
||||
return clientConn
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/metric"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -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,48 @@ 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 s.config.ForceTLS {
|
||||
s.listener, err = tls.Listen("tcp4", addr.String(), s.tlsConfig)
|
||||
} else {
|
||||
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")
|
||||
@@ -134,8 +147,8 @@ func (s *Server) serve(ctx context.Context) {
|
||||
for sessionID := 1; ; sessionID++ {
|
||||
if conn, err := s.listener.Accept(); err != nil {
|
||||
// There was an error accepting the connection.
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||
// Temporary error, sleep for a bit and try again.
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
|
||||
// Timeout, sleep for a bit and try again.
|
||||
if tempDelay == 0 {
|
||||
tempDelay = 5 * time.Millisecond
|
||||
} else {
|
||||
@@ -145,7 +158,7 @@ func (s *Server) serve(ctx context.Context) {
|
||||
tempDelay = max
|
||||
}
|
||||
log.Error().Str("module", "smtp").Err(err).
|
||||
Msgf("SMTP accept error; retrying in %v", tempDelay)
|
||||
Msgf("SMTP accept timeout; retrying in %v", tempDelay)
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
} else {
|
||||
@@ -156,31 +169,25 @@ func (s *Server) serve(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
// Something went wrong.
|
||||
s.emergencyShutdown()
|
||||
s.notify <- err
|
||||
close(s.notify)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
)
|
||||
|
||||
// Context is passed into every request handler function
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -86,13 +85,13 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
|
||||
func spaTemplateHandler(tmpl *template.Template, basePath string,
|
||||
webConfig config.Web) http.Handler {
|
||||
func spaTemplateHandler(tmpl *template.Template, basePath string) http.Handler {
|
||||
tmplData := struct {
|
||||
BasePath string
|
||||
}{
|
||||
BasePath: basePath,
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// ensure we do now allow click jacking
|
||||
w.Header().Set("X-Frame-Options", "SameOrigin")
|
||||
|
||||
@@ -21,6 +21,6 @@ func TextToHTML(text string) string {
|
||||
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
func WrapURL(url string) string {
|
||||
unescaped := strings.Replace(url, "&", "&", -1)
|
||||
unescaped := strings.ReplaceAll(url, "&", "&")
|
||||
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -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,15 @@ func init() {
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method.
|
||||
func Initialize(
|
||||
conf *config.Root,
|
||||
shutdownChan chan bool,
|
||||
mm message.Manager,
|
||||
mh *msghub.Hub) {
|
||||
// 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, mm message.Manager, mh *msghub.Hub) *Server {
|
||||
rootConfig = conf
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers.
|
||||
msgHub = mh
|
||||
@@ -66,6 +65,9 @@ func Initialize(
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||
Msg("Base path configured")
|
||||
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||
|
||||
// Redirect prefix when missing trailing slash.
|
||||
Router.Path(prefix("")).Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||
}
|
||||
|
||||
// Dynamic paths.
|
||||
@@ -107,7 +109,7 @@ func Initialize(
|
||||
|
||||
// SPA managed paths.
|
||||
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||
spaTemplateHandler(indexTmpl, prefix("/")))
|
||||
Router.Path(prefix("/")).Handler(spaHandler)
|
||||
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||
@@ -118,10 +120,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,19 +145,19 @@ 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 {
|
||||
case _ = <-ctx.Done():
|
||||
log.Debug().Str("module", "web").Str("phase", "shutdown").
|
||||
Msg("HTTP server shutting down on request")
|
||||
}
|
||||
<-ctx.Done()
|
||||
log.Debug().Str("module", "web").Str("phase", "shutdown").
|
||||
Msg("HTTP server shutting down on request")
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := listener.Close(); err != nil {
|
||||
@@ -176,26 +184,23 @@ 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)
|
||||
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
case <-ctx.Done():
|
||||
// Nop
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ package file
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -21,7 +25,7 @@ const indexFileName = "index.gob"
|
||||
var (
|
||||
// countChannel is filled with a sequential numbers (0000..9999), which are
|
||||
// used by generateID() to generate unique message IDs. It's global
|
||||
// because we only want one regardless of the number of DataStore objects
|
||||
// because we only want one regardless of the number of DataStore objects.
|
||||
countChannel = make(chan int, 10)
|
||||
)
|
||||
|
||||
@@ -30,7 +34,7 @@ func init() {
|
||||
go countGenerator(countChannel)
|
||||
}
|
||||
|
||||
// Populates the channel with numbers
|
||||
// Populates the channel with numbers.
|
||||
func countGenerator(c chan int) {
|
||||
for i := 0; true; i = (i + 1) % 10000 {
|
||||
c <- i
|
||||
@@ -38,29 +42,33 @@ func countGenerator(c chan int) {
|
||||
}
|
||||
|
||||
// Store implements DataStore aand is the root of the mail storage
|
||||
// hiearchy. It provides access to Mailbox objects
|
||||
// hiearchy. It provides access to Mailbox objects.
|
||||
type Store struct {
|
||||
hashLock storage.HashLock
|
||||
path string
|
||||
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) {
|
||||
// New creates a new DataStore object using the specified path.
|
||||
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")
|
||||
return nil, errors.New("'path' parameter not specified")
|
||||
}
|
||||
mailPath := filepath.Join(path, "mail")
|
||||
|
||||
mailPath := getMailPath(path)
|
||||
if _, err := os.Stat(mailPath); err != nil {
|
||||
// Mail datastore does not yet exist
|
||||
// Mail datastore does not yet exist, create it.
|
||||
if err = os.MkdirAll(mailPath, 0770); err != nil {
|
||||
log.Error().Str("module", "storage").Str("path", mailPath).Err(err).
|
||||
Msg("Error creating dir")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &Store{
|
||||
path: path,
|
||||
mailPath: mailPath,
|
||||
@@ -70,6 +78,7 @@ func New(cfg config.Storage) (storage.Store, error) {
|
||||
return bufio.NewReader(nil)
|
||||
},
|
||||
},
|
||||
extHost: extHost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -82,16 +91,19 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a new message.
|
||||
fm, err := mb.newMessage()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Ensure mailbox directory exists.
|
||||
if err := mb.createDir(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Write the message content
|
||||
|
||||
// Write the message content.
|
||||
file, err := os.Create(fm.rawPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -99,23 +111,24 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
|
||||
w := bufio.NewWriter(file)
|
||||
size, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
// Try to remove the file
|
||||
// Try to remove the file.
|
||||
_ = file.Close()
|
||||
_ = os.Remove(fm.rawPath())
|
||||
return "", err
|
||||
}
|
||||
_ = r.Close()
|
||||
if err := w.Flush(); err != nil {
|
||||
// Try to remove the file
|
||||
// Try to remove the file.
|
||||
_ = file.Close()
|
||||
_ = os.Remove(fm.rawPath())
|
||||
return "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
// Try to remove the file
|
||||
// Try to remove the file.
|
||||
_ = os.Remove(fm.rawPath())
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Update the index.
|
||||
fm.Fdate = m.Date()
|
||||
fm.Ffrom = m.From()
|
||||
@@ -124,10 +137,11 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
|
||||
fm.Fsubject = m.Subject()
|
||||
mb.messages = append(mb.messages, fm)
|
||||
if err := mb.writeIndex(); err != nil {
|
||||
// Try to remove the file
|
||||
// Try to remove the file.
|
||||
_ = os.Remove(fm.rawPath())
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fm.Fid, nil
|
||||
}
|
||||
|
||||
@@ -152,11 +166,13 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
|
||||
mb := fs.mbox(mailbox)
|
||||
mb.Lock()
|
||||
defer mb.Unlock()
|
||||
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range mb.messages {
|
||||
if m.Fid == id {
|
||||
if m.Fseen {
|
||||
@@ -167,6 +183,7 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return mb.writeIndex()
|
||||
}
|
||||
|
||||
@@ -183,6 +200,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()
|
||||
}
|
||||
|
||||
@@ -193,19 +221,22 @@ func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 1 directories
|
||||
|
||||
// Loop over level 1 directories.
|
||||
for _, name1 := range names1 {
|
||||
names2, err := readDirNames(fs.mailPath, name1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
|
||||
// Loop over level 2 directories.
|
||||
for _, name2 := range names2 {
|
||||
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over mailboxes
|
||||
|
||||
// Loop over mailboxes.
|
||||
for _, name3 := range names3 {
|
||||
mb := fs.mboxFromHash(name3)
|
||||
mb.RLock()
|
||||
@@ -230,6 +261,7 @@ func (fs *Store) mbox(mailbox string) *mbox {
|
||||
s2 := hash[0:6]
|
||||
path := filepath.Join(fs.mailPath, s1, s2, hash)
|
||||
indexPath := filepath.Join(path, indexFileName)
|
||||
|
||||
return &mbox{
|
||||
RWMutex: fs.hashLock.Get(hash),
|
||||
store: fs,
|
||||
@@ -246,6 +278,7 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
|
||||
s2 := hash[0:6]
|
||||
path := filepath.Join(fs.mailPath, s1, s2, hash)
|
||||
indexPath := filepath.Join(path, indexFileName)
|
||||
|
||||
return &mbox{
|
||||
RWMutex: fs.hashLock.Get(hash),
|
||||
store: fs,
|
||||
@@ -280,11 +313,23 @@ func generateID(date time.Time) string {
|
||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||
}
|
||||
|
||||
// getMailPath converts a filestore `path` parameter into the effective mail store path.
|
||||
// Within the path, '$' is replaced with ':' to support Windows drive letters with our
|
||||
// env->config map syntax.
|
||||
func getMailPath(base string) string {
|
||||
path := strings.ReplaceAll(base, "$", ":")
|
||||
return filepath.Join(path, "mail")
|
||||
}
|
||||
|
||||
// readDirNames returns a slice of filenames in the specified directory or an error.
|
||||
func readDirNames(elem ...string) ([]string, error) {
|
||||
f, err := os.Open(filepath.Join(elem...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
return f.Readdirnames(0)
|
||||
}
|
||||
|
||||
@@ -2,38 +2,54 @@ package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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 filestore initialization.
|
||||
func TestFSNew(t *testing.T) {
|
||||
// Should fail if no path specified.
|
||||
ds, err := New(config.Storage{}, extension.NewHost())
|
||||
require.ErrorContains(t, err, "parameter not specified")
|
||||
assert.Nil(t, ds)
|
||||
}
|
||||
|
||||
func TestFSGetMailPath(t *testing.T) {
|
||||
// Path should have `mail` dir appended.
|
||||
got := getMailPath(`one`)
|
||||
assert.Regexp(t, "^one.mail$", got, "Expected one/mail or similar")
|
||||
|
||||
// Path should convert `$` to `:`.
|
||||
got = getMailPath(`C$\inbucket`)
|
||||
assert.Regexp(t, "^C:.inbucket.mail$", got, "Expected C:\\inbucket\\mail or similar")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -52,7 +68,7 @@ func TestFSDirStructure(t *testing.T) {
|
||||
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
|
||||
|
||||
// Deliver test message
|
||||
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
|
||||
id1, _ := test.DeliverToStore(t, ds, mbName, "test", time.Now())
|
||||
|
||||
// Check path to message exists
|
||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||
@@ -69,7 +85,7 @@ func TestFSDirStructure(t *testing.T) {
|
||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||
|
||||
// Deliver second test message
|
||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
|
||||
|
||||
// Check files
|
||||
expect = filepath.Join(mbPath, "index.gob")
|
||||
@@ -79,7 +95,7 @@ func TestFSDirStructure(t *testing.T) {
|
||||
|
||||
// Delete message
|
||||
err := ds.RemoveMessage(mbName, id1)
|
||||
assert.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Message should be removed
|
||||
expect = filepath.Join(mbPath, id1+".raw")
|
||||
@@ -89,7 +105,7 @@ func TestFSDirStructure(t *testing.T) {
|
||||
|
||||
// Delete message
|
||||
err = ds.RemoveMessage(mbName, id2)
|
||||
assert.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Message should be removed
|
||||
expect = filepath.Join(mbPath, id2+".raw")
|
||||
@@ -111,7 +127,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"
|
||||
@@ -120,21 +136,21 @@ func TestFSMissing(t *testing.T) {
|
||||
|
||||
for i, subj := range subjects {
|
||||
// Add a message
|
||||
id, _ := deliverMessage(ds, mbName, subj, time.Now())
|
||||
id, _ := test.DeliverToStore(t, ds, mbName, subj, time.Now())
|
||||
sentIds[i] = id
|
||||
}
|
||||
|
||||
// Delete a message file without removing it from index
|
||||
msg, err := ds.GetMessage(mbName, sentIds[1])
|
||||
assert.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
fmsg := msg.(*Message)
|
||||
_ = os.Remove(fmsg.rawPath())
|
||||
msg, err = ds.GetMessage(mbName, sentIds[1])
|
||||
assert.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to read parts of message
|
||||
_, err = msg.Source()
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -146,7 +162,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
|
||||
@@ -155,29 +171,29 @@ func TestGetLatestMessage(t *testing.T) {
|
||||
// Test empty mailbox
|
||||
msg, err := ds.GetMessage(mbName, "latest")
|
||||
assert.Nil(t, msg)
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
|
||||
// Deliver test message
|
||||
deliverMessage(ds, mbName, "test", time.Now())
|
||||
test.DeliverToStore(t, ds, mbName, "test", time.Now())
|
||||
|
||||
// Deliver test message 2
|
||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
|
||||
|
||||
// Test get the latest message
|
||||
msg, err = ds.GetMessage(mbName, "latest")
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id2, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id2)
|
||||
|
||||
// Deliver test message 3
|
||||
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
||||
id3, _ := test.DeliverToStore(t, ds, mbName, "test 3", time.Now())
|
||||
|
||||
msg, err = ds.GetMessage(mbName, "latest")
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id3, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id3)
|
||||
|
||||
// Test wrong id
|
||||
_, err = ds.GetMessage(mbName, "wrongid")
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
@@ -188,47 +204,26 @@ func TestGetLatestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// setupDataStore creates a new FileDataStore in a temporary directory
|
||||
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
|
||||
path, err := ioutil.TempDir("", "inbucket")
|
||||
func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes.Buffer) {
|
||||
path, err := os.MkdirTemp("", "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
|
||||
}
|
||||
|
||||
// deliverMessage creates and delivers a message to the specific mailbox, returning
|
||||
// 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{
|
||||
Mailbox: mbName,
|
||||
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
|
||||
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
|
||||
Subject: subject,
|
||||
Date: date,
|
||||
}
|
||||
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
|
||||
meta.To[0].Address, meta.From.Address, subject)
|
||||
delivery := &message.Delivery{
|
||||
Meta: meta,
|
||||
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
|
||||
}
|
||||
id, err := ds.AddMessage(delivery)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id, int64(len(testMsg))
|
||||
return s.(*Store), buf
|
||||
}
|
||||
|
||||
func teardownDataStore(ds *Store) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/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
|
||||
}
|
||||
}
|
||||
@@ -107,6 +112,8 @@ func (mb *mbox) readIndex() error {
|
||||
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
|
||||
Msg("Index does not yet exist")
|
||||
mb.indexLoaded = true
|
||||
|
||||
//lint:ignore nilerr missing mailboxes are considered empty.
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(mb.indexPath)
|
||||
@@ -125,7 +132,7 @@ func (mb *mbox) readIndex() error {
|
||||
dec := gob.NewDecoder(br)
|
||||
name := ""
|
||||
if err = dec.Decode(&name); err != nil {
|
||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
}
|
||||
mb.name = name
|
||||
for {
|
||||
@@ -135,7 +142,7 @@ func (mb *mbox) readIndex() error {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
}
|
||||
msg.mailbox = mb
|
||||
mb.messages = append(mb.messages, msg)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// access in most cases without requiring an infinite number of mutexes.
|
||||
type HashLock [4096]sync.RWMutex
|
||||
|
||||
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
|
||||
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexadecimal
|
||||
// string of three or more characters.
|
||||
func (h *HashLock) Get(hash string) *sync.RWMutex {
|
||||
if len(hash) < 3 {
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
func TestHashLock(t *testing.T) {
|
||||
@@ -36,7 +36,7 @@ func TestHashLock(t *testing.T) {
|
||||
t.Run(ts, func(t *testing.T) {
|
||||
l := hl.Get(ts)
|
||||
if l == nil {
|
||||
t.Errorf("Expeced non-nil lock for hex string %q", ts)
|
||||
t.Errorf("Expected non-nil lock for hex string %q", ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
// Message is a memory store message.
|
||||
@@ -47,7 +46,7 @@ func (m *Message) Subject() string { return m.subject }
|
||||
|
||||
// Source returns a reader for the message source.
|
||||
func (m *Message) Source() (io.ReadCloser, error) {
|
||||
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
|
||||
return io.NopCloser(bytes.NewReader(m.source)), nil
|
||||
}
|
||||
|
||||
// Size returns the message size in bytes.
|
||||
|
||||
@@ -2,13 +2,15 @@ package mem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
// Store implements an in-memory message store.
|
||||
@@ -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 {
|
||||
@@ -30,11 +33,12 @@ type mbox struct {
|
||||
|
||||
var _ storage.Store = &Store{}
|
||||
|
||||
// New returns an emtpy memory store.
|
||||
func New(cfg config.Storage) (storage.Store, error) {
|
||||
// New returns an empty memory store.
|
||||
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)
|
||||
@@ -58,7 +62,7 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
||||
err = ierr
|
||||
return
|
||||
}
|
||||
source, ierr := ioutil.ReadAll(r)
|
||||
source, ierr := io.ReadAll(r)
|
||||
if ierr != nil {
|
||||
err = ierr
|
||||
return
|
||||
@@ -78,6 +82,7 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
||||
m.id = id
|
||||
m.source = source
|
||||
mb.messages[id] = m
|
||||
|
||||
if s.cap > 0 {
|
||||
// Enforce cap.
|
||||
for len(mb.messages) > s.cap {
|
||||
@@ -140,16 +145,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 +177,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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,28 +5,34 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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,19 +44,23 @@ 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 {
|
||||
err := s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
for _, m := range messages {
|
||||
gotSize += m.Size()
|
||||
}
|
||||
return true
|
||||
})
|
||||
require.NoError(t, err, "VisitMailboxes() must succeed")
|
||||
|
||||
// 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 +68,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))
|
||||
@@ -71,11 +82,14 @@ func TestMaxSize(t *testing.T) {
|
||||
}(mailbox)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify zero stored messages.
|
||||
count := 0
|
||||
s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
err = s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
count += len(messages)
|
||||
return true
|
||||
})
|
||||
require.NoError(t, err, "VisitMailboxes() must succeed")
|
||||
if count != 0 {
|
||||
t.Errorf("Got %v total messages, want: %v", count, 0)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package storage
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"expvar"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/metric"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -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,18 +1,21 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -20,28 +23,32 @@ func TestDoRetentionScan(t *testing.T) {
|
||||
old1 := stubMessage("mb1", 4)
|
||||
old2 := stubMessage("mb1", 12)
|
||||
old3 := stubMessage("mb2", 24)
|
||||
ds.AddMessage(new1)
|
||||
ds.AddMessage(old1)
|
||||
ds.AddMessage(old2)
|
||||
ds.AddMessage(old3)
|
||||
ds.AddMessage(new2)
|
||||
ds.AddMessage(new3)
|
||||
_, _ = ds.AddMessage(new1)
|
||||
_, _ = ds.AddMessage(old1)
|
||||
_, _ = ds.AddMessage(old2)
|
||||
_, _ = 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),
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package stringutil
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
func HashMailboxName(mailbox string) string {
|
||||
h := sha1.New()
|
||||
if _, err := io.WriteString(h, mailbox); err != nil {
|
||||
// This shouldn't ever happen
|
||||
// This should never happen.
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// StringAddress converts an Address to a UTF-8 string.
|
||||
@@ -74,3 +75,38 @@ func MakePathPrefixer(prefix string) func(string) string {
|
||||
return prefix + path
|
||||
}
|
||||
}
|
||||
|
||||
// MatchWithWildcards tests if a "s" string matches a "p" pattern with wildcards (*, ?)
|
||||
func MatchWithWildcards(p string, s string) bool {
|
||||
runeInput := []rune(s)
|
||||
runePattern := []rune(p)
|
||||
lenInput := len(runeInput)
|
||||
lenPattern := len(runePattern)
|
||||
isMatchingMatrix := make([][]bool, lenInput+1)
|
||||
for i := range isMatchingMatrix {
|
||||
isMatchingMatrix[i] = make([]bool, lenPattern+1)
|
||||
}
|
||||
isMatchingMatrix[0][0] = true
|
||||
if lenPattern > 0 {
|
||||
if runePattern[0] == '*' {
|
||||
isMatchingMatrix[0][1] = true
|
||||
}
|
||||
}
|
||||
for j := 2; j <= lenPattern; j++ {
|
||||
if runePattern[j-1] == '*' {
|
||||
isMatchingMatrix[0][j] = isMatchingMatrix[0][j-1]
|
||||
}
|
||||
}
|
||||
for i := 1; i <= lenInput; i++ {
|
||||
for j := 1; j <= lenPattern; j++ {
|
||||
if runePattern[j-1] == '*' {
|
||||
isMatchingMatrix[i][j] = isMatchingMatrix[i-1][j] || isMatchingMatrix[i][j-1]
|
||||
}
|
||||
|
||||
if runePattern[j-1] == '?' || runeInput[i-1] == runePattern[j-1] {
|
||||
isMatchingMatrix[i][j] = isMatchingMatrix[i-1][j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return isMatchingMatrix[lenInput][lenPattern]
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHashMailboxName(t *testing.T) {
|
||||
want := "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
|
||||
got := stringutil.HashMailboxName("mail")
|
||||
if got != want {
|
||||
t.Errorf("Got %q, want %q", got, want)
|
||||
}
|
||||
want := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
||||
got := stringutil.HashMailboxName("")
|
||||
assert.Equal(t, want, got, "for empty string")
|
||||
|
||||
want = "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
|
||||
got = stringutil.HashMailboxName("mail")
|
||||
assert.Equal(t, want, got, "for 'mail'")
|
||||
}
|
||||
|
||||
func TestStringAddressList(t *testing.T) {
|
||||
@@ -76,3 +79,49 @@ func TestMakePathPrefixer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchWithWildcards(t *testing.T) {
|
||||
testCases := []struct {
|
||||
pattern, input string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "", input: "", want: true},
|
||||
{pattern: "", input: "qwerty", want: false},
|
||||
{pattern: "qw*ty", input: "qwerty", want: true},
|
||||
{pattern: "qw?ty", input: "qwerty", want: false},
|
||||
{pattern: "qwe*ty", input: "qwerty", want: true},
|
||||
{pattern: "*erty", input: "qwerty", want: true},
|
||||
{pattern: "?erty", input: "qwerty", want: false},
|
||||
{pattern: "?werty", input: "qwerty", want: true},
|
||||
{pattern: "qwer*", input: "qwerty", want: true},
|
||||
{pattern: "qwer?", input: "qwerty", want: false},
|
||||
{pattern: "qwert?", input: "qwerty", want: true},
|
||||
{pattern: "qw**ty", input: "qwerty", want: true},
|
||||
{pattern: "qw??ty", input: "qwerty", want: true},
|
||||
{pattern: "qwe??ty", input: "qwerty", want: false},
|
||||
{pattern: "**erty", input: "qwerty", want: true},
|
||||
{pattern: "??erty", input: "qwerty", want: true},
|
||||
{pattern: "??werty", input: "qwerty", want: false},
|
||||
{pattern: "qwer**", input: "qwerty", want: true},
|
||||
{pattern: "qwer??", input: "qwerty", want: true},
|
||||
{pattern: "qwert??", input: "qwerty", want: false},
|
||||
{pattern: "q?er?y", input: "qwerty", want: true},
|
||||
{pattern: "q?r?y", input: "qwerty", want: false},
|
||||
{pattern: "q*er*y", input: "qwerty", want: true},
|
||||
{pattern: "q*r*y", input: "qwerty", want: true},
|
||||
{pattern: "q*?werty", input: "qwerty", want: false},
|
||||
{pattern: "q*?erty", input: "qwerty", want: true},
|
||||
{pattern: "q?*werty", input: "qwerty", want: false},
|
||||
{pattern: "q?*erty", input: "qwerty", want: true},
|
||||
{pattern: "?*rty", input: "qwerty", want: true},
|
||||
{pattern: "*?rty", input: "qwerty", want: true},
|
||||
{pattern: "qwe?*", input: "qwerty", want: true},
|
||||
{pattern: "qwe*?", input: "qwerty", want: true},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
got := stringutil.MatchWithWildcards(tc.pattern, tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("Test %s with pattern %s, Got: %v, want: %v", tc.input, tc.pattern, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,30 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
smtpclient "net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest"
|
||||
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/v3/pkg/webui"
|
||||
"github.com/jhillyerd/goldiff"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,111 +35,129 @@ const (
|
||||
smtpHost = "127.0.0.1:2500"
|
||||
)
|
||||
|
||||
func TestSuite(t *testing.T) {
|
||||
stopServer, err := startServer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopServer()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
test func(*testing.T)
|
||||
}{
|
||||
{"basic", testBasic},
|
||||
{"fullname", testFullname},
|
||||
{"encodedHeader", testEncodedHeader},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, tc.test)
|
||||
}
|
||||
// TODO: Add suites for domain and full addressing modes.
|
||||
type IntegrationSuite struct {
|
||||
suite.Suite
|
||||
stopServer func()
|
||||
}
|
||||
|
||||
func testBasic(t *testing.T) {
|
||||
func (s *IntegrationSuite) SetupSuite() {
|
||||
stopServer, err := startServer()
|
||||
s.Require().NoError(err)
|
||||
s.stopServer = stopServer
|
||||
}
|
||||
|
||||
func (s *IntegrationSuite) TearDownSuite() {
|
||||
s.stopServer()
|
||||
}
|
||||
|
||||
func TestIntegrationSuite(t *testing.T) {
|
||||
suite.Run(t, new(IntegrationSuite))
|
||||
}
|
||||
|
||||
func (s *IntegrationSuite) TestBasic() {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("basic.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(msg)
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "basic.golden")
|
||||
goldiff.File(s.T(), got, "testdata", "basic.golden")
|
||||
}
|
||||
|
||||
func testFullname(t *testing.T) {
|
||||
func (s *IntegrationSuite) TestFullname() {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("fullname.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(msg)
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "fullname.golden")
|
||||
goldiff.File(s.T(), got, "testdata", "fullname.golden")
|
||||
}
|
||||
|
||||
func testEncodedHeader(t *testing.T) {
|
||||
func (s *IntegrationSuite) TestEncodedHeader() {
|
||||
client, err := client.New(restBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
from := "fromuser@inbucket.org"
|
||||
to := []string{"recipient@inbucket.org"}
|
||||
input := readTestData("encodedheader.txt")
|
||||
|
||||
// Send mail.
|
||||
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("recipient", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg == nil {
|
||||
t.Errorf("Got nil message, wanted non-nil message.")
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(msg)
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(t, got, "testdata", "encodedheader.golden")
|
||||
goldiff.File(s.T(), got, "testdata", "encodedheader.golden")
|
||||
}
|
||||
|
||||
func (s *IntegrationSuite) TestIPv4Recipient() {
|
||||
client, err := client.New(restBaseURL)
|
||||
s.Require().NoError(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)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("ip4recipient", "latest")
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(msg)
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(s.T(), got, "testdata", "no-to-ipv4.golden")
|
||||
}
|
||||
|
||||
func (s *IntegrationSuite) TestIPv6Recipient() {
|
||||
client, err := client.New(restBaseURL)
|
||||
s.Require().NoError(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)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Confirm receipt.
|
||||
msg, err := client.GetMessage("ip6recipient", "latest")
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(msg)
|
||||
|
||||
// Compare to golden.
|
||||
got := formatMessage(msg)
|
||||
goldiff.File(s.T(), got, "testdata", "no-to-ipv6.golden")
|
||||
}
|
||||
|
||||
func formatMessage(m *client.Message) []byte {
|
||||
@@ -152,40 +173,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()
|
||||
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
|
||||
}
|
||||
@@ -197,9 +224,28 @@ func readTestData(path ...string) []byte {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data, err := ioutil.ReadAll(f)
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// clearEnv clears environment variables, preserving any that are critical for this OS.
|
||||
func clearEnv() {
|
||||
preserve := make(map[string]string)
|
||||
backup := func(k string) {
|
||||
preserve[k] = os.Getenv(k)
|
||||
}
|
||||
|
||||
// Backup ciritcal env variables.
|
||||
if runtime.GOOS == "windows" {
|
||||
backup("SYSTEMROOT")
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
|
||||
for k, v := range preserve {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
// ManagerStub is a test stub for message.Manager
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
// StoreStub stubs storage.Store for testing.
|
||||
type StoreStub struct {
|
||||
storage.Store
|
||||
mailboxes map[string][]storage.Message
|
||||
deleted map[storage.Message]struct{}
|
||||
mailboxes map[string][]storage.Message // Stored messages, by mailbox.
|
||||
deleted map[storage.Message]struct{} // Deleted message references.
|
||||
}
|
||||
|
||||
// NewStore creates a new StoreStub.
|
||||
@@ -25,7 +25,7 @@ func NewStore() *StoreStub {
|
||||
func (s *StoreStub) AddMessage(m storage.Message) (id string, err error) {
|
||||
mb := m.Mailbox()
|
||||
msgs := s.mailboxes[mb]
|
||||
s.mailboxes[mb] = append(msgs, m)
|
||||
s.mailboxes[mb] = append(msgs, &MessageStub{Message: m})
|
||||
return m.ID(), nil
|
||||
}
|
||||
|
||||
@@ -50,34 +50,72 @@ func (s *StoreStub) GetMessages(mailbox string) ([]storage.Message, error) {
|
||||
return s.mailboxes[mailbox], nil
|
||||
}
|
||||
|
||||
// RemoveMessage deletes a message by ID from the specified mailbox.
|
||||
func (s *StoreStub) RemoveMessage(mailbox, id string) error {
|
||||
mb, ok := s.mailboxes[mailbox]
|
||||
if ok {
|
||||
var msg storage.Message
|
||||
for i, m := range mb {
|
||||
if m.ID() == id {
|
||||
msg = m
|
||||
s.mailboxes[mailbox] = append(mb[:i], mb[i+1:]...)
|
||||
break
|
||||
// MarkSeen marks the message as having been seen.
|
||||
func (s *StoreStub) MarkSeen(mailbox, id string) error {
|
||||
if mailbox == "messageerr" {
|
||||
return errors.New("internal error")
|
||||
}
|
||||
for _, m := range s.mailboxes[mailbox] {
|
||||
if m.ID() == id {
|
||||
if stub, ok := m.(*MessageStub); ok {
|
||||
stub.seen = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if msg != nil {
|
||||
s.deleted[msg] = struct{}{}
|
||||
return nil
|
||||
return errors.New("unexpected type in StoreStub.mailboxes")
|
||||
}
|
||||
}
|
||||
return storage.ErrNotExist
|
||||
}
|
||||
|
||||
// RemoveMessage deletes a message by ID from the specified mailbox.
|
||||
func (s *StoreStub) RemoveMessage(mailbox, id string) error {
|
||||
if mb, ok := s.mailboxes[mailbox]; ok {
|
||||
var removed storage.Message
|
||||
for i, m := range mb {
|
||||
if m.ID() == id {
|
||||
removed = m
|
||||
s.mailboxes[mailbox] = append(mb[:i], mb[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if removed != nil {
|
||||
// Clients will be checking for their original storage.Message, not our wrapper.
|
||||
if stub, ok := removed.(*MessageStub); ok {
|
||||
s.deleted[stub.Message] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
return errors.New("unexpected type in StoreStub.mailboxes")
|
||||
}
|
||||
}
|
||||
|
||||
return storage.ErrNotExist
|
||||
}
|
||||
|
||||
// PurgeMessages deletes the contents of a mailbox.
|
||||
func (s *StoreStub) PurgeMessages(mailbox string) error {
|
||||
for _, removed := range s.mailboxes[mailbox] {
|
||||
// Clients will be checking for their original storage.Message, not our wrapper.
|
||||
if stub, ok := removed.(*MessageStub); ok {
|
||||
s.deleted[stub.Message] = struct{}{}
|
||||
} else {
|
||||
return errors.New("unexpected type in StoreStub.mailboxes")
|
||||
}
|
||||
}
|
||||
|
||||
s.mailboxes[mailbox] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||
// continues to return true.
|
||||
func (s *StoreStub) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||
for _, v := range s.mailboxes {
|
||||
if !f(v) {
|
||||
for _, msgs := range s.mailboxes {
|
||||
if !f(msgs) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -86,3 +124,14 @@ func (s *StoreStub) MessageDeleted(m storage.Message) bool {
|
||||
_, ok := s.deleted[m]
|
||||
return ok
|
||||
}
|
||||
|
||||
// MessageStub wraps a storage.Message with "seen" functionality.
|
||||
type MessageStub struct {
|
||||
storage.Message
|
||||
seen bool
|
||||
}
|
||||
|
||||
// Seen returns true if the message has been marked as seen previously.
|
||||
func (m *MessageStub) Seen() bool {
|
||||
return m.seen
|
||||
}
|
||||
|
||||
61
pkg/test/storage_helpers.go
Normal file
61
pkg/test/storage_helpers.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||
)
|
||||
|
||||
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
|
||||
// generated message.
|
||||
func DeliverToStore(
|
||||
t *testing.T,
|
||||
store storage.Store,
|
||||
mailbox string,
|
||||
subject string,
|
||||
date time.Time,
|
||||
) (string, int64) {
|
||||
t.Helper()
|
||||
meta := event.MessageMetadata{
|
||||
Mailbox: mailbox,
|
||||
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
|
||||
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
|
||||
Subject: subject,
|
||||
Date: date,
|
||||
}
|
||||
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
|
||||
meta.To[0].Address, meta.From.Address, subject)
|
||||
delivery := &message.Delivery{
|
||||
Meta: meta,
|
||||
Reader: io.NopCloser(strings.NewReader(testMsg)),
|
||||
}
|
||||
|
||||
id, err := store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return id, int64(len(testMsg))
|
||||
}
|
||||
|
||||
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
|
||||
// also checks return error.
|
||||
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
|
||||
t.Helper()
|
||||
msgs, err := s.GetMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
|
||||
}
|
||||
if len(msgs) != count {
|
||||
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
@@ -3,25 +3,38 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||
"github.com/inbucket/inbucket/v3/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 is passed to each test function; embeds `testing.T` to provide testing primitives.
|
||||
type storeSuite struct {
|
||||
*testing.T
|
||||
store storage.Store
|
||||
extHost *extension.Host
|
||||
}
|
||||
|
||||
// StoreSuite runs a set of general tests on the provided Store.
|
||||
func StoreSuite(t *testing.T, factory StoreFactory) {
|
||||
t.Helper()
|
||||
testCases := []struct {
|
||||
name string
|
||||
test func(*testing.T, storage.Store)
|
||||
test func(storeSuite)
|
||||
conf config.Storage
|
||||
}{
|
||||
{"metadata", testMetadata, config.Storage{}},
|
||||
@@ -39,18 +52,25 @@ 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)
|
||||
destroy()
|
||||
defer destroy()
|
||||
|
||||
s := storeSuite{
|
||||
T: t,
|
||||
store: store,
|
||||
extHost: extHost,
|
||||
}
|
||||
tc.test(s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testMetadata verifies message metadata is stored and retrieved correctly.
|
||||
func testMetadata(t *testing.T, store storage.Store) {
|
||||
func testMetadata(s storeSuite) {
|
||||
mailbox := "testmailbox"
|
||||
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
|
||||
to := []*mail.Address{
|
||||
@@ -61,7 +81,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,
|
||||
@@ -72,52 +92,52 @@ func testMetadata(t *testing.T, store storage.Store) {
|
||||
},
|
||||
Reader: strings.NewReader(content),
|
||||
}
|
||||
id, err := store.AddMessage(delivery)
|
||||
id, err := s.store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("Expected AddMessage() to return non-empty ID string")
|
||||
s.Fatal("Expected AddMessage() to return non-empty ID string")
|
||||
}
|
||||
// Retrieve and validate the message.
|
||||
sm, err := store.GetMessage(mailbox, id)
|
||||
sm, err := s.store.GetMessage(mailbox, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if sm.Mailbox() != mailbox {
|
||||
t.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
|
||||
s.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
|
||||
}
|
||||
if sm.ID() != id {
|
||||
t.Errorf("got id %q, want: %q", sm.ID(), id)
|
||||
s.Errorf("got id %q, want: %q", sm.ID(), id)
|
||||
}
|
||||
if *sm.From() != *from {
|
||||
t.Errorf("got from %v, want: %v", sm.From(), from)
|
||||
s.Errorf("got from %v, want: %v", sm.From(), from)
|
||||
}
|
||||
if len(sm.To()) != len(to) {
|
||||
t.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
|
||||
s.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
|
||||
} else {
|
||||
for i, got := range sm.To() {
|
||||
if *to[i] != *got {
|
||||
t.Errorf("got to[%v] %v, want: %v", i, got, to[i])
|
||||
s.Errorf("got to[%v] %v, want: %v", i, got, to[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sm.Date().Equal(date) {
|
||||
t.Errorf("got date %v, want: %v", sm.Date(), date)
|
||||
s.Errorf("got date %v, want: %v", sm.Date(), date)
|
||||
}
|
||||
if sm.Subject() != subject {
|
||||
t.Errorf("got subject %q, want: %q", sm.Subject(), subject)
|
||||
s.Errorf("got subject %q, want: %q", sm.Subject(), subject)
|
||||
}
|
||||
if sm.Size() != int64(len(content)) {
|
||||
t.Errorf("got size %v, want: %v", sm.Size(), len(content))
|
||||
s.Errorf("got size %v, want: %v", sm.Size(), len(content))
|
||||
}
|
||||
if sm.Seen() {
|
||||
t.Errorf("got seen %v, want: false", sm.Seen())
|
||||
s.Errorf("got seen %v, want: false", sm.Seen())
|
||||
}
|
||||
}
|
||||
|
||||
// testContent generates some binary content and makes sure it is correctly retrieved.
|
||||
func testContent(t *testing.T, store storage.Store) {
|
||||
func testContent(s storeSuite) {
|
||||
content := make([]byte, 5000)
|
||||
for i := 0; i < len(content); i++ {
|
||||
content[i] = byte(i % 256)
|
||||
@@ -130,7 +150,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,
|
||||
@@ -140,305 +160,290 @@ func testContent(t *testing.T, store storage.Store) {
|
||||
},
|
||||
Reader: bytes.NewReader(content),
|
||||
}
|
||||
id, err := store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Get and check.
|
||||
m, err := store.GetMessage(mailbox, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := s.store.AddMessage(delivery)
|
||||
require.NoError(s, err, "AddMessage() failed")
|
||||
|
||||
// Read stored message source.
|
||||
m, err := s.store.GetMessage(mailbox, id)
|
||||
require.NoError(s, err, "GetMessage() failed")
|
||||
r, err := m.Source()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(s, err, "Source() failed")
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(s, err, "failed to read source")
|
||||
err = r.Close()
|
||||
require.NoError(s, err, "failed to close source reader")
|
||||
|
||||
// Verify source.
|
||||
if len(got) != len(content) {
|
||||
t.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
|
||||
s.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
|
||||
}
|
||||
errors := 0
|
||||
for i, b := range got {
|
||||
if b != content[i] {
|
||||
t.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
|
||||
s.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
|
||||
errors++
|
||||
}
|
||||
if errors > 5 {
|
||||
t.Fatalf("Too many content errors, aborting test.")
|
||||
break
|
||||
s.Fatalf("Too many content errors, aborting test.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(s storeSuite) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
for i, subj := range subjects {
|
||||
// Check mailbox count.
|
||||
GetAndCountMessages(t, store, mailbox, i)
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
GetAndCountMessages(s.T, s.store, mailbox, i)
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
}
|
||||
// Confirm delivery order.
|
||||
msgs := GetAndCountMessages(t, store, mailbox, 5)
|
||||
msgs := GetAndCountMessages(s.T, s.store, mailbox, 5)
|
||||
for i, want := range subjects {
|
||||
got := msgs[i].Subject()
|
||||
if got != want {
|
||||
t.Errorf("Got subject %q, want %q", got, want)
|
||||
s.Errorf("Got subject %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(s storeSuite) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
}
|
||||
// Confirm latest.
|
||||
latest, err := store.GetMessage(mailbox, "latest")
|
||||
latest, err := s.store.GetMessage(mailbox, "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
|
||||
s.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
|
||||
}
|
||||
got := latest.Subject()
|
||||
want := "echo"
|
||||
if got != want {
|
||||
t.Errorf("Got subject %q, want %q", got, want)
|
||||
s.Errorf("Got subject %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// testNaming ensures the store does not enforce local part mailbox naming.
|
||||
func testNaming(t *testing.T, store storage.Store) {
|
||||
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
||||
GetAndCountMessages(t, store, "fred", 0)
|
||||
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
||||
func testNaming(s storeSuite) {
|
||||
DeliverToStore(s.T, s.store, "fred@fish.net", "disk #27", time.Now())
|
||||
GetAndCountMessages(s.T, s.store, "fred", 0)
|
||||
GetAndCountMessages(s.T, s.store, "fred@fish.net", 1)
|
||||
}
|
||||
|
||||
// testSize verifies message content size metadata values.
|
||||
func testSize(t *testing.T, store storage.Store) {
|
||||
func testSize(s storeSuite) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"a", "br", "much longer than the others"}
|
||||
sentIds := make([]string, len(subjects))
|
||||
sentSizes := make([]int64, len(subjects))
|
||||
for i, subj := range subjects {
|
||||
id, size := DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
id, size := DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
sentIds[i] = id
|
||||
sentSizes[i] = size
|
||||
}
|
||||
for i, id := range sentIds {
|
||||
msg, err := store.GetMessage(mailbox, id)
|
||||
msg, err := s.store.GetMessage(mailbox, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
want := sentSizes[i]
|
||||
got := msg.Size()
|
||||
if got != want {
|
||||
t.Errorf("Got size %v, want: %v", got, want)
|
||||
s.Errorf("Got size %v, want: %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testSeen verifies a message can be marked as seen.
|
||||
func testSeen(t *testing.T, store storage.Store) {
|
||||
func testSeen(s storeSuite) {
|
||||
mailbox := "lisa"
|
||||
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
|
||||
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
|
||||
id1, _ := DeliverToStore(s.T, s.store, mailbox, "whatever", time.Now())
|
||||
id2, _ := DeliverToStore(s.T, s.store, mailbox, "hello?", time.Now())
|
||||
// Confirm unseen.
|
||||
msg, err := store.GetMessage(mailbox, id1)
|
||||
msg, err := s.store.GetMessage(mailbox, id1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if msg.Seen() {
|
||||
t.Errorf("got seen %v, want: false", msg.Seen())
|
||||
s.Errorf("got seen %v, want: false", msg.Seen())
|
||||
}
|
||||
// Mark id1 seen.
|
||||
err = store.MarkSeen(mailbox, id1)
|
||||
err = s.store.MarkSeen(mailbox, id1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
// Verify id1 seen.
|
||||
msg, err = store.GetMessage(mailbox, id1)
|
||||
msg, err = s.store.GetMessage(mailbox, id1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if !msg.Seen() {
|
||||
t.Errorf("id1 got seen %v, want: true", msg.Seen())
|
||||
s.Errorf("id1 got seen %v, want: true", msg.Seen())
|
||||
}
|
||||
// Verify id2 still unseen.
|
||||
msg, err = store.GetMessage(mailbox, id2)
|
||||
msg, err = s.store.GetMessage(mailbox, id2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
s.Fatal(err)
|
||||
}
|
||||
if msg.Seen() {
|
||||
t.Errorf("id2 got seen %v, want: false", msg.Seen())
|
||||
s.Errorf("id2 got seen %v, want: false", msg.Seen())
|
||||
}
|
||||
}
|
||||
|
||||
// testDelete creates and deletes some messages.
|
||||
func testDelete(t *testing.T, store storage.Store) {
|
||||
func testDelete(s storeSuite) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
}
|
||||
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
msgs := GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
|
||||
|
||||
// Subscribe to events.
|
||||
eventListener := s.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 := s.store.RemoveMessage(mailbox, id)
|
||||
require.NoError(s, err)
|
||||
}
|
||||
|
||||
// Confirm deletion.
|
||||
subjects = []string{"alpha", "charlie", "echo"}
|
||||
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
|
||||
for i, want := range subjects {
|
||||
got := msgs[i].Subject()
|
||||
if got != want {
|
||||
t.Errorf("Got subject %q, want %q", got, want)
|
||||
s.Errorf("Got subject %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture events and check correct IDs were emitted.
|
||||
ev1, err := eventListener()
|
||||
require.NoError(s, err)
|
||||
ev2, err := eventListener()
|
||||
require.NoError(s, err)
|
||||
eventIDs := []string{ev1.ID, ev2.ID}
|
||||
for _, id := range deleteIDs {
|
||||
assert.Contains(s, eventIDs, id)
|
||||
}
|
||||
|
||||
// Try appending one more.
|
||||
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
|
||||
DeliverToStore(s.T, s.store, mailbox, "foxtrot", time.Now())
|
||||
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
|
||||
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
|
||||
for i, want := range subjects {
|
||||
got := msgs[i].Subject()
|
||||
if got != want {
|
||||
t.Errorf("Got subject %q, want %q", got, want)
|
||||
s.Errorf("Got subject %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testPurge makes sure mailboxes can be purged.
|
||||
func testPurge(t *testing.T, store storage.Store) {
|
||||
func testPurge(s storeSuite) {
|
||||
mailbox := "fred"
|
||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
|
||||
// Subscribe to events.
|
||||
eventListener := s.extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
|
||||
|
||||
// Populate mailbox.
|
||||
for _, subj := range subjects {
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
}
|
||||
GetAndCountMessages(t, store, mailbox, len(subjects))
|
||||
GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
|
||||
|
||||
// Purge and verify.
|
||||
err := store.PurgeMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
err := s.store.PurgeMessages(mailbox)
|
||||
require.NoError(s, err)
|
||||
GetAndCountMessages(s.T, s.store, mailbox, 0)
|
||||
|
||||
// Confirm events emitted.
|
||||
gotEvents := []*event.MessageMetadata{}
|
||||
for range subjects {
|
||||
ev, err := eventListener()
|
||||
if err != nil {
|
||||
s.Error(err)
|
||||
break
|
||||
}
|
||||
gotEvents = append(gotEvents, ev)
|
||||
}
|
||||
GetAndCountMessages(t, store, mailbox, 0)
|
||||
assert.Equal(s, 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(s storeSuite) {
|
||||
mbCap := 10
|
||||
mailbox := "captain"
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
subj := fmt.Sprintf("subject %v", i)
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
msgs, err := store.GetMessages(mailbox)
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
msgs, err := s.store.GetMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
|
||||
s.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
|
||||
}
|
||||
if len(msgs) > mbCap {
|
||||
t.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
|
||||
s.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
|
||||
break
|
||||
}
|
||||
// Check that the first message is correct.
|
||||
|
||||
// Check that the first (oldest) message is correct.
|
||||
first := i - mbCap + 1
|
||||
if first < 0 {
|
||||
first = 0
|
||||
}
|
||||
firstSubj := fmt.Sprintf("subject %v", first)
|
||||
if firstSubj != msgs[0].Subject() {
|
||||
t.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
|
||||
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testNoMsgCap verfies a cap of 0 is not enforced.
|
||||
func testNoMsgCap(t *testing.T, store storage.Store) {
|
||||
func testNoMsgCap(s storeSuite) {
|
||||
mailbox := "captain"
|
||||
for i := 0; i < 20; i++ {
|
||||
subj := fmt.Sprintf("subject %v", i)
|
||||
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||
GetAndCountMessages(t, store, mailbox, i+1)
|
||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||
GetAndCountMessages(s.T, s.store, mailbox, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
|
||||
// them.
|
||||
func testVisitMailboxes(t *testing.T, ds storage.Store) {
|
||||
func testVisitMailboxes(s storeSuite) {
|
||||
// Deliver 2 test messages to each of 5 mailboxes.
|
||||
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
|
||||
for _, name := range boxes {
|
||||
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
|
||||
DeliverToStore(t, ds, name, "New Message", time.Now())
|
||||
DeliverToStore(s.T, s.store, name, "Old Message", time.Now().Add(-24*time.Hour))
|
||||
DeliverToStore(s.T, s.store, name, "New Message", time.Now())
|
||||
}
|
||||
seen := 0
|
||||
err := ds.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
seen++
|
||||
count := len(messages)
|
||||
if count != 2 {
|
||||
t.Errorf("got: %v messages, want: 2", count)
|
||||
|
||||
// Verify message and mailbox counts.
|
||||
nboxes := 0
|
||||
err := s.store.VisitMailboxes(func(messages []storage.Message) bool {
|
||||
nboxes++
|
||||
name := "unknown"
|
||||
if len(messages) > 0 {
|
||||
name = messages[0].Mailbox()
|
||||
}
|
||||
|
||||
assert.Len(s, messages, 2, "incorrect message count in mailbox %s", name)
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if seen != 5 {
|
||||
t.Errorf("saw %v messages in total, want: 5", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
|
||||
// generated message.
|
||||
func DeliverToStore(
|
||||
t *testing.T,
|
||||
store storage.Store,
|
||||
mailbox string,
|
||||
subject string,
|
||||
date time.Time,
|
||||
) (string, int64) {
|
||||
t.Helper()
|
||||
meta := message.Metadata{
|
||||
Mailbox: mailbox,
|
||||
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
|
||||
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
|
||||
Subject: subject,
|
||||
Date: date,
|
||||
}
|
||||
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
|
||||
meta.To[0].Address, meta.From.Address, subject)
|
||||
delivery := &message.Delivery{
|
||||
Meta: meta,
|
||||
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
|
||||
}
|
||||
id, err := store.AddMessage(delivery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id, int64(len(testMsg))
|
||||
}
|
||||
|
||||
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
|
||||
// also checks return error.
|
||||
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
|
||||
t.Helper()
|
||||
msgs, err := s.GetMessages(mailbox)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
|
||||
}
|
||||
if len(msgs) != count {
|
||||
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
|
||||
}
|
||||
return msgs
|
||||
require.NoError(s, err, "VisitMailboxes() failed")
|
||||
assert.Equal(s, 5, nboxes, "visited %v mailboxes, want: 5", nboxes)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user