1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-11-05 17:12:33 +00:00

Compare commits

..

61 Commits

Author SHA1 Message Date
James Hillyerd 87888e9dbf Update changelog for 3.1.0 beta1 (#353)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-28 11:00:39 -08:00
James Hillyerd e84d21cb28 events: Remove unnecessary go calls (#352) 2023-02-28 10:25:16 -08:00
James Hillyerd 5a886813c3 Provide inbucket object in Lua (#351)
* fix delve fortify thingy
* Expose inbucket.after.message_stored in lua
* Expose inbucket.after.message_deleted in lua
* Expose inbucket.before.mail_accepted in lua
2023-02-27 20:22:10 -08:00
James Hillyerd 95281566f6 bump Go deps (#350)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-27 10:59:53 -08:00
James Hillyerd 7044567d64 msghub: Clear deleted messages instead of unlinking (#348)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-17 15:06:43 -08:00
James Hillyerd 82ddf2141c Create V2 API for monitor+deletes, revert V1 API (#347)
* Revert socketv1 controller API to maintain V1 contract, introduce
V2 controller for Inbucket UI.

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Introduce MessageID for deletes, instead of recycling header

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Update UI for monitor V2 API

Signed-off-by: James Hillyerd <james@hillyerd.com>

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-17 12:37:17 -08:00
James Hillyerd b554c7db83 Fix doc name for LUA config param (#339)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-17 12:35:08 -08:00
James Hillyerd 36095a2cdf extension: split out an async specific broker for "after" events (#346)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-16 16:17:06 -08:00
James Hillyerd e1b8996412 goreleaser: set archive rlcp true to mirror upcoming default (#344)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-16 11:25:28 -08:00
James Hillyerd 71d3e8df3b goreleaser: correct archive naming by using default (#343)
.Binary was causing some archives to be called inbucket_client

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-16 10:59:57 -08:00
James Hillyerd 2da7ad61cd ui: update browser list (#342) 2023-02-16 09:59:26 -08:00
James Hillyerd eaae1a1e44 GHA: Use node16 actions, Go 1.20 (#340)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-15 21:01:24 -08:00
James Hillyerd a55da8b7d1 file & mem stores should emit delete events on purge (#338)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-14 19:51:42 -08:00
James Hillyerd 561ed93451 feat: Monitor tab dynamically updates when messages are deleted (#337)
* WIP: msghub handles deletes, UI does not yet display them

Signed-off-by: James Hillyerd <james@hillyerd.com>

* socket and UI support message deletes

Signed-off-by: James Hillyerd <james@hillyerd.com>

* use Delete naming for consistency

Signed-off-by: James Hillyerd <james@hillyerd.com>

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-14 19:02:06 -08:00
James Hillyerd ef12d02b83 msghub: Recover and log panics (#336)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-14 14:00:13 -08:00
James Hillyerd de617b6a73 delete event: test lua func (#335)
* lua: tidy test helpers

Signed-off-by: James Hillyerd <james@hillyerd.com>

* lua: test after_message_deleted func

Signed-off-by: James Hillyerd <james@hillyerd.com>

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-14 10:56:43 -08:00
James Hillyerd 69b6225554 storage: emit AfterMessageDeleted events (#334)
* Ignore test lua script

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Wire ExtHost into storage system
imports

Signed-off-by: James Hillyerd <james@hillyerd.com>

* storage/file: emit deleted events

Signed-off-by: James Hillyerd <james@hillyerd.com>

* storage/mem: emit deleted events

Signed-off-by: James Hillyerd <james@hillyerd.com>

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-13 17:11:04 -08:00
James Hillyerd 5adef42df7 Replace message.Metadata usage with event.MessageMetadata (#333)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-13 13:52:28 -08:00
James Hillyerd d11ae3710c config: make note of domain addressing (#331) 2023-02-12 16:17:41 -08:00
James Hillyerd 5d18d79539 lua: Preload json module (#330)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-10 11:44:36 -08:00
James Hillyerd b38b2e9760 lua: Preload gluahttp module (#328) 2023-02-09 19:04:33 -08:00
James Hillyerd 75b7c69b5c lua: Add getter/setter tests for bound objects (#326)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-08 17:20:57 -08:00
James Hillyerd 239426692e lua: Use table syntax for user object bindings (#325)
* lua: update bind_message to use table syntax

* lua: update bind_address to use table syntax
2023-02-08 13:38:00 -08:00
James Hillyerd 17b054b5a1 add: direnv config (#324)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-02-08 10:32:19 -08:00
James Hillyerd 7f91c3e9cb lua: Bind after_message_stored and before_mail_accepted (#322)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-24 16:37:26 -08:00
James Hillyerd 55addbb556 lua: Init with config and pool (#321)
* lua: Intial impl with config and pool

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-24 12:16:58 -08:00
James Hillyerd 8fd5cdfc86 extension: Add BeforeMailAccepted event (#320)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-22 18:01:56 -08:00
James Hillyerd e74efbaa77 extension: Make AfterMessageStored async (#319) 2023-01-22 16:26:52 -08:00
James Hillyerd b383fbf9ab manager: Test that MessageStored event is emitted (#318)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-17 19:26:44 -08:00
James Hillyerd c9912bc2bb Fix incorrect test capitalization (#317)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-17 19:08:59 -08:00
James Hillyerd f0d457b8f5 extension: Add MessageStored event (#316)
* Replace existing direct StoreManager->msghub communication with this
  event
* For #280 #309 #312 #310

Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-16 21:30:47 -08:00
James Hillyerd 3bf4b5c39b extension: Implement an EventBroker (#315)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-16 20:34:16 -08:00
James Hillyerd 37806f222d shell.nix: add a quick-test script, qt (#314) 2023-01-16 20:15:40 -08:00
James Hillyerd f5899c293c etc: scripts use /usr/bin/env bash (#313)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-16 16:10:27 -08:00
James Hillyerd cd9c3d61ee shell.nix: Add gopls as build dep (#308)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-16 14:29:38 -08:00
James Hillyerd 37d314fd2e Makefile: fix, wasn't testing SRC timestamps (#307)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-15 16:41:11 -08:00
James Hillyerd 28b0557865 Update go.mod to 1.18 syntax (#306)
Signed-off-by: James Hillyerd <james@hillyerd.com>

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-13 09:09:28 -08:00
James Hillyerd 997cb55847 Update go deps (#304)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-01-12 20:28:24 -08:00
James Hillyerd 61454a0c9c Build with Go 1.19 (#305)
Also adds `:edge` tag to dev docker-run.sh script
2023-01-12 20:19:40 -08:00
dependabot[bot] e875a4c382 build(deps): bump json5 from 2.2.1 to 2.2.3 in /ui (#302)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-08 10:09:25 -08:00
James Hillyerd bc6548b6f3 Update CHANGELOG for 3.0.4 2022-10-02 15:35:59 -07:00
James Hillyerd 911a6c8d78 bump nix go to 1.18 2022-10-02 13:14:01 -07:00
James Hillyerd 547a12ffca smtp: Adjust fromRegex to handle AUTH=<> in middle of args (#291)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-09-03 14:03:55 -07:00
James Hillyerd c8d22ac802 smtp: Break up handler tests (#290)
* Do drain logging from main to reduce test output
* Break up some of the larger handler test funcs
* Introduce sub-tests
2022-08-25 14:19:57 -07:00
James Hillyerd 9dbffa88de Refactor SMTP handler_test log collection (#289)
* smtp: allow logger to be passed into startSession
* smtp: remove logbuf and teardown from handler_test
* smtp: handler_test log output to testing.T
2022-08-24 21:42:53 -07:00
James Hillyerd eae4926b23 Dependency injection improvements (#288)
Refactor server life-cycle into it's own file, make service startup and monitoring more consistent and testable.

* Extract services creation in preparation for DI
* pop3: rename New to NewServer
* lifecycle: Add fatal error Notify()
* web: Introduce Server struct w/ Notify()
* Extract Start in lifecycle
* Add Start() to Hub
* RetentionScanner startup consistent with other svcs
* Remove global shutdown channel
* Implement a readiness notification system
2022-08-13 13:22:34 -07:00
Abirdcfly 29d1ed1e7f delete minor unreachable code caused by t.Fatal (#287)
Signed-off-by: Abirdcfly <fp544037857@gmail.com>
2022-08-10 08:59:09 -07:00
James Hillyerd 1f1a8b4192 Handle IP address domains (#285)
* Add basic TestRecipientAddress tests

* Handle forward-path route spec

* Validate IP addr "domains"

* Forward-path test cases

* Add integration test

* Add IPv4 recip swaks test

* Special case domain mailbox extraction

* add IPv6 swaks test

* Formatting

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Update changelog
2022-08-07 20:13:58 -07:00
James Hillyerd 344c3ffb21 Update CHANGELOG for 3.0.3 (#286)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-08-07 19:39:48 -07:00
James Hillyerd 87018ed42d Allow AUTH=<> FROM parameter (#284)
* Use backtick on regex
* Accept AUTH=<> FROM parameter
* Update changelog
2022-07-30 10:57:29 -07:00
James Hillyerd 1650a5b375 Merge pull request #281 from inbucket/dependabot/npm_and_yarn/ui/terser-5.14.2
build(deps): bump terser from 5.12.1 to 5.14.2 in /ui
2022-07-30 09:41:41 -07:00
dependabot[bot] 3f7adbfb22 build(deps): bump terser from 5.12.1 to 5.14.2 in /ui
Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 02:45:45 +00:00
James Hillyerd 03cc31fb70 Build with Go 1.18 2022-07-04 16:23:06 -07:00
James Hillyerd a10a6244c9 Merge pull request #279 from inbucket/v302rel
Release 3.0.2
2022-07-04 12:02:17 -07:00
James Hillyerd 9185423022 Update CHANGELOG 2022-07-04 11:03:48 -07:00
James Hillyerd 9aaca449f8 Fix non-root basepaths, closes #273 2022-05-22 21:47:52 -07:00
James Hillyerd f39395bd7f Fix error in swaks tests server default env var 2022-05-22 21:47:52 -07:00
James Hillyerd 2c68128d5d swaks-tests: allow server address override 2022-05-08 13:13:25 -07:00
James Hillyerd 06d4120682 Migrate to Yarn & Parcel (#260)
* Switch from npm to yarn
* Add minimum viable parcel dev server config
* Remove webpack configs
* Update docker build, build w/ yarn on node 16.x
2022-04-23 13:35:54 -07:00
James Hillyerd 58bcd4f557 Docker frontend build runs as amd64 (#270) 2022-04-23 10:33:46 -07:00
kaustubh105 e91e8d5aee Add support for ARMv7 and ARM64 docker images (#267)
* Build armv7 and arm64 docker containers

* Add QEMU step to build for multi arch

* Pin qemu action version
2022-02-25 08:56:18 -08:00
86 changed files with 5921 additions and 8670 deletions
+1
View File
@@ -0,0 +1 @@
use nix
+3 -3
View File
@@ -6,14 +6,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go: [ '1.17', '1.16' ] go: [ '1.20', '1.19' ]
name: Go ${{ matrix.go }} build name: Go ${{ matrix.go }} build
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v2 uses: actions/setup-go@v3
with: with:
go-version: ${{ matrix.go }} go-version: ${{ matrix.go }}
- name: Build and test - name: Build and test
+11 -6
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: | images: |
inbucket/inbucket inbucket/inbucket
@@ -25,25 +25,30 @@ jobs:
type=edge,branch=main type=edge,branch=main
flavor: | flavor: |
latest=auto latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64, linux/arm/v7
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+11 -13
View File
@@ -8,34 +8,32 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v2 uses: actions/setup-go@v3
with: with:
go-version: 1.17 go-version: 1.19
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v3
with: with:
node-version: '14.x' node-version: '16.x'
- name: Setup Elm cache: 'yarn'
uses: jorelali/setup-elm@v2 cache-dependency-path: ui/yarn.lock
with:
elm-version: 0.19.1
- name: Build frontend - name: Build frontend
run: | run: |
npm ci yarn install --frozen-lockfile --non-interactive
npm run build yarn run build
working-directory: ./ui working-directory: ./ui
- name: Test build release - name: Test build release
uses: goreleaser/goreleaser-action@v2 uses: goreleaser/goreleaser-action@v4
if: "!startsWith(github.ref, 'refs/tags/v')" if: "!startsWith(github.ref, 'refs/tags/v')"
with: with:
version: latest version: latest
args: release --snapshot args: release --snapshot
- name: Build and publish release - name: Build and publish release
uses: goreleaser/goreleaser-action@v2 uses: goreleaser/goreleaser-action@v4
if: "startsWith(github.ref, 'refs/tags/v')" if: "startsWith(github.ref, 'refs/tags/v')"
with: with:
version: latest version: latest
+6
View File
@@ -30,6 +30,8 @@ tags.*
# Desktop Services Store on macOS # Desktop Services Store on macOS
.DS_Store .DS_Store
/.direnv
# Inbucket binaries # Inbucket binaries
/client /client
/client.exe /client.exe
@@ -52,3 +54,7 @@ repl-temp-*
/ui/dist/ /ui/dist/
# Dependency directories # Dependency directories
/ui/node_modules /ui/node_modules
/ui/.parcel-cache
# Test lua files
/inbucket.lua
+1 -2
View File
@@ -48,10 +48,9 @@ builds:
archives: archives:
- id: tarball - id: tarball
rlcp: true
format: tar.gz format: tar.gz
wrap_in_directory: true wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip format: zip
+42 -3
View File
@@ -4,7 +4,42 @@ Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased] ## [v3.1.0-beta1] - 2023-02-28
### Added
- Monitor tab updates when messages are deleted (#337)
- Initial framework for extensions
- Initial Lua scripting implementation, supporting events:
- after.message_deleted
- after.message_stored
- before.mail_accepted
- Provide `http` and `json` modules for Lua scripts
### Fixed
- Support for IP address as domain in RCPT TO (#285)
## [v3.0.4] - 2022-10-02
### Fixed
- More flexible support of `AUTH=<>` FROM parameter (#291)
## [v3.0.3] - 2022-08-07
### Fixed
- Support for `AUTH=<>` FROM parameter (#284)
## [v3.0.2] - 2022-07-04
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
details.
### Changed
- arm Docker builds now rely on amd64 frontend build stage
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
## [v3.0.1-rc2] - 2022-01-23 ## [v3.0.1-rc2] - 2022-01-23
@@ -293,10 +328,14 @@ No change from beta1.
specific message. specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...main [Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...main
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2 [v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1 [v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0 [v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4 [v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2 [v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1 [v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
+17 -19
View File
@@ -1,35 +1,33 @@
# Docker build file for Inbucket: https://www.inbucket.org/ # Docker build file for Inbucket: https://www.inbucket.org/
# Install build-time dependencies ### Build frontend
FROM golang:1.17-alpine3.14 as builder # Due to no official elm compiler for arm; build frontend with amd64.
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3 FROM --platform=linux/amd64 node:16 as frontend
WORKDIR /build
COPY . .
WORKDIR /build/ui
RUN rm -rf .parcel-cache dist elm-stuff node_modules
RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.19-alpine3.17 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build WORKDIR /build
COPY . . COPY . .
ENV CGO_ENABLED 0 ENV CGO_ENABLED 0
RUN make clean deps RUN make clean deps
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm ci
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
# Build server
WORKDIR /build
RUN go build -o inbucket \ RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \ -ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket -v ./cmd/inbucket
# Build frontend ### Run in minimal image
WORKDIR /build/ui FROM alpine:3.17
RUN npm run build
# Run in minimal image
FROM alpine:3.14
RUN apk --no-cache add tzdata RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket WORKDIR /opt/inbucket
RUN mkdir bin defaults ui RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin COPY --from=backend /build/inbucket bin
COPY --from=builder /build/ui/dist ui COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults COPY etc/docker/defaults/greeting.html defaults
COPY etc/docker/defaults/start-inbucket.sh / COPY etc/docker/defaults/start-inbucket.sh /
+1 -1
View File
@@ -9,7 +9,7 @@ commands = client inbucket
all: clean test lint build all: clean test lint build
$(commands): %: cmd/% $(commands): %: cmd/% $(SRC)
go build ./$< go build ./$<
clean: clean:
+3 -4
View File
@@ -33,14 +33,13 @@ tracks our tagged releases, and `edge` tracks our potentially unstable
## Building from Source ## Building from Source
You will need functioning [Go], [Elm] and [Node.js] installations for this to You will need functioning [Go] and [Node.js] installations for this to work.
work.
```sh ```sh
git clone https://github.com/inbucket/inbucket.git git clone https://github.com/inbucket/inbucket.git
cd inbucket/ui cd inbucket/ui
npm ci yarn install
npm run build yarn build
cd .. cd ..
go build ./cmd/inbucket go build ./cmd/inbucket
``` ```
+22 -43
View File
@@ -15,18 +15,10 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/server"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/server/pop3"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file" "github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem" "github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -114,36 +106,16 @@ func main() {
} }
} }
// Configure internal services. // Configure and start internal services.
rootCtx, rootCancel := context.WithCancel(context.Background()) svcCtx, svcCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool) services, err := server.FullAssembly(conf)
store, err := storage.FromConfig(conf.Storage)
if err != nil { if err != nil {
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
removePIDFile(*pidfile) removePIDFile(*pidfile)
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
} }
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory) services.Start(svcCtx, func() {
addrPolicy := &policy.Addressing{Config: conf} startupLog.Debug().Msg("All services report ready")
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub} })
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// 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)
// Loop forever waiting for signals or shutdown channel. // Loop forever waiting for signals or shutdown channel.
signalLoop: signalLoop:
@@ -155,24 +127,31 @@ signalLoop:
// Shutdown requested // Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT"). log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
Msg("Received SIGINT, shutting down") Msg("Received SIGINT, shutting down")
close(shutdownChan) svcCancel()
break signalLoop
case syscall.SIGTERM: case syscall.SIGTERM:
// Shutdown requested // Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM"). log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
Msg("Received SIGTERM, shutting down") Msg("Received SIGTERM, shutting down")
close(shutdownChan) svcCancel()
break signalLoop
} }
case <-shutdownChan: case <-services.Notify():
rootCancel() log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
svcCancel()
break signalLoop break signalLoop
} }
} }
// Wait for active connections to finish. // Wait for active connections to finish.
go timedExit(*pidfile) go timedExit(*pidfile)
smtpServer.Drain() log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
pop3Server.Drain() services.SMTPServer.Drain()
retentionScanner.Join() log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
services.POP3Server.Drain()
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
services.RetentionScanner.Join()
removePIDFile(*pidfile) removePIDFile(*pidfile)
closeLog() closeLog()
} }
+12 -1
View File
@@ -9,7 +9,8 @@ variables it supports:
KEY DEFAULT DESCRIPTION KEY DEFAULT DESCRIPTION
INBUCKET_LOGLEVEL info debug, info, warn, or error INBUCKET_LOGLEVEL info debug, info, warn, or error
INBUCKET_MAILBOXNAMING local Use local or full addressing INBUCKET_LUA_PATH inbucket.lua Lua script path
INBUCKET_MAILBOXNAMING local Use local, full, or domain addressing
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
INBUCKET_SMTP_DOMAIN inbucket HELO domain INBUCKET_SMTP_DOMAIN inbucket HELO domain
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
@@ -56,6 +57,16 @@ off with `warn` or `error`.
- Default: `info` - Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error` - Values: one of `debug`, `info`, `warn`, or `error`
### Lua Script
`INBUCKET_LUA_PATH`
This is the path to the (optional) Inbucket Lua script. If the specified file
is present, Inbucket will load it during startup. Ignored if the file is not
found, or the setting is empty.
- Default: `inbucket.lua`
### Mailbox Naming ### Mailbox Naming
`INBUCKET_MAILBOXNAMING` `INBUCKET_MAILBOXNAMING`
+1
View File
@@ -3,6 +3,7 @@
# description: Developer friendly Inbucket configuration # description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug" export INBUCKET_LOGLEVEL="debug"
#export INBUCKET_MAILBOXNAMING="domain"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local" export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false" #export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local" export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
+1 -1
View File
@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image # description: Launch Inbucket's docker image
# Docker Image Tag # Docker Image Tag
IMAGE="inbucket/inbucket" IMAGE="inbucket/inbucket:edge"
# Ports exposed on host: # Ports exposed on host:
PORT_HTTP=9000 PORT_HTTP=9000
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# rest-apiv1.sh # rest-apiv1.sh
# description: Script to access Inbucket REST API version 1 # description: Script to access Inbucket REST API version 1
+6 -2
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# run-tests.sh # run-tests.sh
# description: Generate test emails for Inbucket # description: Generate test emails for Inbucket
@@ -24,7 +24,7 @@ case "$1" in
;; ;;
esac esac
export SWAKS_OPT_server="127.0.0.1:2500" export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
export SWAKS_OPT_to="$to@inbucket.local" export SWAKS_OPT_to="$to@inbucket.local"
# Basic test # Basic test
@@ -59,3 +59,7 @@ swaks $* --data nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary # Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw swaks $* --data mime-errors.raw
# IP RCPT domain
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body text.txt
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body text.txt
+32 -13
View File
@@ -1,23 +1,42 @@
module github.com/inbucket/inbucket module github.com/inbucket/inbucket
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/subcommands v1.2.0 github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0 github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.5.0
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect github.com/inbucket/gopher-json v0.2.0
github.com/jhillyerd/enmime v0.9.2 github.com/jhillyerd/enmime v0.11.0
github.com/jhillyerd/goldiff v0.1.0 github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.13 // indirect github.com/microcosm-cc/bluemonday v1.0.22
github.com/microcosm-cc/bluemonday v1.0.17 github.com/rs/zerolog v1.29.0
github.com/rs/zerolog v1.26.1 github.com/stretchr/testify v1.8.2
github.com/stretchr/testify v1.7.0 github.com/yuin/gopher-lua v1.1.0
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d golang.org/x/net v0.7.0
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
) )
go 1.13 require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.18
+70 -37
View File
@@ -2,12 +2,16 @@ 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/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 h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= 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/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.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.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/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-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 h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
@@ -18,74 +22,103 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/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 h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 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.11.0 h1:5EOSLh7l3eMODznfMCKVGQY74Qb95Yfuet6CYgquLfM=
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8= github.com/jhillyerd/enmime v0.11.0/go.mod h1:nw2aJ34YXWklLze+qEESgP+KNhU3fMQuiFsD/4soh3Q=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A= github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs= github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.22 h1:p2tT7RNzRdCi0qmwxG+HbqD6ILkmwter1ZwVZn1oTxA=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/microcosm-cc/bluemonday v1.0.22/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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-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-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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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-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/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-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.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/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8 -2
View File
@@ -58,14 +58,20 @@ func (n *mbNaming) Decode(v string) error {
// Root contains global configuration, and structs with for specific sub-systems. // Root contains global configuration, and structs with for specific sub-systems.
type Root struct { type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"` LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"` Lua Lua
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
SMTP SMTP SMTP SMTP
POP3 POP3 POP3 POP3
Web Web Web Web
Storage Storage Storage Storage
} }
// Lua contains the Lua extension host configuration.
type Lua struct {
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
}
// SMTP contains the SMTP server configuration. // SMTP contains the SMTP server configuration.
type SMTP struct { type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"` Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
+89
View 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
View File
@@ -0,0 +1,101 @@
package extension_test
import (
"testing"
"time"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Simple smoke test without using AsyncTestListener.
func TestAsyncBrokerEmitCallsOneListener(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listener.
events := make(chan string, 1)
listener := func(s string) {
events <- s
}
broker.AddListener("x", listener)
want := "bacon"
broker.Emit(&want)
var got string
select {
case event := <-events:
got = event
case <-time.After(time.Second * 2):
t.Fatal("Timeout waiting for event")
}
if got != want {
t.Errorf("Emit got %q, want %q", got, want)
}
}
func TestAsyncBrokerEmitCallsMultipleListeners(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("first", 1)
second := broker.AsyncTestListener("second", 1)
want := "hi"
broker.Emit(&want)
first_got, err := first()
require.NoError(t, err)
assert.Equal(t, want, *first_got)
second_got, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
}
func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("dup", 1)
second := broker.AsyncTestListener("dup", 1)
want := "hi"
broker.Emit(&want)
first_got, err := first()
require.Error(t, err)
assert.Nil(t, first_got)
second_got, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
}
func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("1", 1)
second := broker.AsyncTestListener("2", 1)
broker.RemoveListener("1")
want := "hi"
broker.Emit(&want)
first_got, err := first()
require.Error(t, err)
assert.Nil(t, first_got)
second_got, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
}
func TestAsyncBrokerRemovingMissingListener(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
broker.RemoveListener("doesn't crash")
}
+59
View File
@@ -0,0 +1,59 @@
package extension
import (
"sync"
)
// EventBroker maintains a list of listeners interested in a specific type
// of event.
type EventBroker[E any, R comparable] struct {
sync.RWMutex
listenerNames []string // Ordered listener names.
listenerFuncs []func(E) *R // Ordered listener functions.
}
// Emit sends the provided event to each registered listener in order, until
// one returns a non-nil result. That result will be returned to the caller.
func (eb *EventBroker[E, R]) Emit(event *E) *R {
eb.RLock()
defer eb.RUnlock()
for _, l := range eb.listenerFuncs {
// Events are copied to minimize the risk of mutation.
if result := l(*event); result != nil {
return result
}
}
return nil
}
// AddListener registers the named listener, replacing one with a duplicate
// name if present. Listeners should be added in order of priority, most
// significant first.
func (eb *EventBroker[E, R]) AddListener(name string, listener func(E) *R) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
eb.listenerNames = append(eb.listenerNames, name)
eb.listenerFuncs = append(eb.listenerFuncs, listener)
}
// RemoveListener unregisters the named listener.
func (eb *EventBroker[E, R]) RemoveListener(name string) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
}
func (eb *EventBroker[E, R]) lockedRemoveListener(name string) {
for i, entry := range eb.listenerNames {
if entry == name {
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
break
}
}
}
+134
View File
@@ -0,0 +1,134 @@
package extension_test
import (
"testing"
"github.com/inbucket/inbucket/pkg/extension"
)
func TestBrokerEmitCallsOneListener(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listener.
var got string
listener := func(s string) *bool {
got = s
return nil
}
broker.AddListener("x", listener)
want := "bacon"
broker.Emit(&want)
if got != want {
t.Errorf("Emit got %q, want %q", got, want)
}
}
func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
first := func(s string) *bool {
first_got = s
return nil
}
second := func(s string) *bool {
second_got = s
return nil
}
broker.AddListener("1", first)
broker.AddListener("2", second)
want := "hi"
broker.Emit(&want)
if first_got != want {
t.Errorf("first got %q, want %q", first_got, want)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
}
}
func TestBrokerEmitCapturesFirstResult(t *testing.T) {
broker := &extension.EventBroker[struct{}, string]{}
// Setup listeners.
makeListener := func(result *string) func(struct{}) *string {
return func(s struct{}) *string { return result }
}
first := "first"
second := "second"
broker.AddListener("0", makeListener(nil))
broker.AddListener("1", makeListener(&first))
broker.AddListener("2", makeListener(&second))
want := first
got := broker.Emit(&struct{}{})
if got == nil {
t.Errorf("Emit got nil, want %q", want)
} else if *got != want {
t.Errorf("Emit got %q, want %q", *got, want)
}
}
func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
first := func(s string) *bool {
first_got = s
return nil
}
second := func(s string) *bool {
second_got = s
return nil
}
broker.AddListener("dup", first)
broker.AddListener("dup", second)
want := "hi"
broker.Emit(&want)
if first_got != "" {
t.Errorf("first got %q, want empty string", first_got)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
}
}
func TestBrokerRemovingListenerSuccessful(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
first := func(s string) *bool {
first_got = s
return nil
}
second := func(s string) *bool {
second_got = s
return nil
}
broker.AddListener("1", first)
broker.AddListener("2", second)
broker.RemoveListener("1")
want := "hi"
broker.Emit(&want)
if first_got != "" {
t.Errorf("first got %q, want empty string", first_got)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
}
}
func TestBrokerRemovingMissingListener(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
broker.RemoveListener("doesn't crash")
}
+24
View File
@@ -0,0 +1,24 @@
package event
import (
"net/mail"
"time"
)
// AddressParts contains the local and domain parts of an email address.
type AddressParts struct {
Local string
Domain string
}
// MessageMetadata contains the basic header data for a message event.
type MessageMetadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}
+34
View File
@@ -0,0 +1,34 @@
package extension
import (
"github.com/inbucket/inbucket/pkg/extension/event"
)
// Host defines extension points for Inbucket.
type Host struct {
Events *Events
}
// Events defines all the event types supported by the extension host.
//
// Before-events provide an opportunity for extensions to alter how Inbucket responds to that type
// of event. These events are processed synchronously; expensive operations will reduce the
// perceived performance of Inbucket. The first listener in the list to respond with a non-nil
// value will determine the response, and the remaining listeners will not be called.
//
// After-events allow extensions to take an action after an event has completed. These events are
// processed asynchronously with respect to the rest of Inbuckets operation. However, an event
// listener will not be called until the one before it complets.
type Events struct {
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
BeforeMailAccepted EventBroker[event.AddressParts, bool]
}
// Void indicates the event emitter will ignore any value returned by listeners.
type Void struct{}
// NewHost creates a new extension host.
func NewHost() *Host {
return &Host{Events: &Events{}}
}
+92
View 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
}
@@ -0,0 +1,54 @@
package luahost
import (
"net/mail"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestMailAddressGetters(t *testing.T) {
want := &mail.Address{
Name: "Roberto I",
Address: "ri@example.com",
}
script := `
assert(addr, "addr should not be nil")
want = "Roberto I"
got = addr.name
assert(got == want, string.format("got name %q, want %q", got, want))
want = "ri@example.com"
got = addr.address
assert(got == want, string.format("got address %q, want %q", got, want))
`
ls := lua.NewState()
registerMailAddressType(ls)
ls.SetGlobal("addr", wrapMailAddress(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestMailAddressSetters(t *testing.T) {
want := &mail.Address{
Name: "Roberto I",
Address: "ri@example.com",
}
script := `
assert(addr, "addr should not be nil")
addr.name = "Roberto I"
addr.address = "ri@example.com"
`
got := &mail.Address{}
ls := lua.NewState()
registerMailAddressType(ls)
ls.SetGlobal("addr", wrapMailAddress(ls, got))
require.NoError(t, ls.DoString(script))
assert.Equal(t, want, got)
}
+218
View File
@@ -0,0 +1,218 @@
package luahost
import (
"errors"
"fmt"
lua "github.com/yuin/gopher-lua"
)
const (
inbucketName = "inbucket"
inbucketBeforeName = "inbucket_before"
inbucketAfterName = "inbucket_after"
)
// Inbucket is the primary Lua interface data structure.
type Inbucket struct {
After InbucketAfterFuncs
Before InbucketBeforeFuncs
}
// InbucketAfterFuncs holds references to Lua extension functions to be called async
// after Inbucket handles an event.
type InbucketAfterFuncs struct {
MessageDeleted *lua.LFunction
MessageStored *lua.LFunction
}
// InbucketBeforeFuncs holds references to Lua extension functions to be called
// before Inbucket handles an event.
type InbucketBeforeFuncs struct {
MailAccepted *lua.LFunction
}
func registerInbucketTypes(ls *lua.LState) {
// inbucket type.
mt := ls.NewTypeMetatable(inbucketName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketIndex))
// inbucket global var.
ud := wrapInbucket(ls, &Inbucket{})
ls.SetGlobal(inbucketName, ud)
// inbucket.after type.
mt = ls.NewTypeMetatable(inbucketAfterName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketAfterNewIndex))
// inbucket.before type.
mt = ls.NewTypeMetatable(inbucketBeforeName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketBeforeIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketBeforeNewIndex))
}
func wrapInbucket(ls *lua.LState, val *Inbucket) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketName))
return ud
}
func wrapInbucketAfter(ls *lua.LState, val *InbucketAfterFuncs) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketAfterName))
return ud
}
func wrapInbucketBefore(ls *lua.LState, val *InbucketBeforeFuncs) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketBeforeName))
return ud
}
func getInbucket(ls *lua.LState) (*Inbucket, error) {
lv := ls.GetGlobal(inbucketName)
if lv == nil {
return nil, errors.New("inbucket object was nil")
}
ud, ok := lv.(*lua.LUserData)
if !ok {
return nil, fmt.Errorf("inbucket object was type %s instead of UserData", lv.Type())
}
val, ok := ud.Value.(*Inbucket)
if !ok {
return nil, fmt.Errorf("inbucket object (%v) could not be cast", ud.Value)
}
return val, nil
}
func checkInbucket(ls *lua.LState, pos int) *Inbucket {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*Inbucket); ok {
return val
}
ls.ArgError(1, inbucketName+" expected")
return nil
}
func checkInbucketAfter(ls *lua.LState, pos int) *InbucketAfterFuncs {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*InbucketAfterFuncs); ok {
return val
}
ls.ArgError(1, inbucketAfterName+" expected")
return nil
}
func checkInbucketBefore(ls *lua.LState, pos int) *InbucketBeforeFuncs {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*InbucketBeforeFuncs); ok {
return val
}
ls.ArgError(1, inbucketBeforeName+" expected")
return nil
}
// inbucket getter.
func inbucketIndex(ls *lua.LState) int {
ib := checkInbucket(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "after":
ls.Push(wrapInbucketAfter(ls, &ib.After))
case "before":
ls.Push(wrapInbucketBefore(ls, &ib.Before))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// inbucket.after getter.
func inbucketAfterIndex(ls *lua.LState) int {
after := checkInbucketAfter(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "message_deleted":
ls.Push(funcOrNil(after.MessageDeleted))
case "message_stored":
ls.Push(funcOrNil(after.MessageStored))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// inbucket.after setter.
func inbucketAfterNewIndex(ls *lua.LState) int {
m := checkInbucketAfter(ls, 1)
index := ls.CheckString(2)
switch index {
case "message_deleted":
m.MessageDeleted = ls.CheckFunction(3)
case "message_stored":
m.MessageStored = ls.CheckFunction(3)
default:
ls.RaiseError("invalid inbucket.after index %q", index)
}
return 0
}
// inbucket.before getter.
func inbucketBeforeIndex(ls *lua.LState) int {
before := checkInbucketBefore(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "mail_accepted":
ls.Push(funcOrNil(before.MailAccepted))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// inbucket.before setter.
func inbucketBeforeNewIndex(ls *lua.LState) int {
m := checkInbucketBefore(ls, 1)
index := ls.CheckString(2)
switch index {
case "mail_accepted":
m.MailAccepted = ls.CheckFunction(3)
default:
ls.RaiseError("invalid inbucket.before index %q", index)
}
return 0
}
func funcOrNil(f *lua.LFunction) lua.LValue {
if f == nil {
return lua.LNil
}
return f
}
+102
View File
@@ -0,0 +1,102 @@
package luahost
import (
"testing"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestInbucketAfterFuncs(t *testing.T) {
// This Script registers each function and calls it. No effort is made to use the arguments
// that Inbucket expects, this is only to validate the inbucket.after data structure getters
// and setters.
script := `
assert(inbucket, "inbucket should not be nil")
assert(inbucket.after, "inbucket.after should not be nil")
local fns = { "message_deleted", "message_stored" }
-- Verify functions start off nil.
for i, name in ipairs(fns) do
assert(inbucket.after[name] == nil, "after." .. name .. " should be nil")
end
-- Test function to track func calls made, ensures no crossed wires.
local calls = {}
function makeTestFunc(create_name)
return function(call_name)
calls[create_name] = call_name
end
end
-- Set after functions, verify not nil, and call them.
for i, name in ipairs(fns) do
inbucket.after[name] = makeTestFunc(name)
assert(inbucket.after[name], "after." .. name .. " should not be nil")
end
-- Call each function. Separate loop to verify final state in 'calls'.
for i, name in ipairs(fns) do
inbucket.after[name](name)
end
-- Verify functions were called.
for i, name in ipairs(fns) do
assert(calls[name], "after." .. name .. " should have been called")
assert(calls[name] == name,
string.format("after.%s was called with incorrect argument %s", name, calls[name]))
end
`
ls := lua.NewState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}
func TestInbucketBeforeFuncs(t *testing.T) {
// This Script registers each function and calls it. No effort is made to use the arguments
// that Inbucket expects, this is only to validate the inbucket.before data structure getters
// and setters.
script := `
assert(inbucket, "inbucket should not be nil")
assert(inbucket.before, "inbucket.before should not be nil")
local fns = { "mail_accepted" }
-- Verify functions start off nil.
for i, name in ipairs(fns) do
assert(inbucket.before[name] == nil, "before." .. name .. " should be nil")
end
-- Test function to track func calls made, ensures no crossed wires.
local calls = {}
function makeTestFunc(create_name)
return function(call_name)
calls[create_name] = call_name
end
end
-- Set before functions, verify not nil, and call them.
for i, name in ipairs(fns) do
inbucket.before[name] = makeTestFunc(name)
assert(inbucket.before[name], "before." .. name .. " should not be nil")
end
-- Call each function. Separate loop to verify final state in 'calls'.
for i, name in ipairs(fns) do
inbucket.before[name](name)
end
-- Verify functions were called.
for i, name in ipairs(fns) do
assert(calls[name], "before." .. name .. " should have been called")
assert(calls[name] == name,
string.format("before.%s was called with incorrect argument %s", name, calls[name]))
end
`
ls := lua.NewState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}
+119
View File
@@ -0,0 +1,119 @@
package luahost
import (
"net/mail"
"time"
"github.com/inbucket/inbucket/pkg/extension/event"
lua "github.com/yuin/gopher-lua"
)
const messageMetadataName = "message_metadata"
func registerMessageMetadataType(ls *lua.LState) {
mt := ls.NewTypeMetatable(messageMetadataName)
ls.SetGlobal(messageMetadataName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newMessageMetadata))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(messageMetadataIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(messageMetadataNewIndex))
}
func newMessageMetadata(ls *lua.LState) int {
val := &event.MessageMetadata{}
ud := wrapMessageMetadata(ls, val)
ls.Push(ud)
return 1
}
func wrapMessageMetadata(ls *lua.LState, val *event.MessageMetadata) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(messageMetadataName))
return ud
}
func checkMessageMetadata(ls *lua.LState, pos int) *event.MessageMetadata {
ud := ls.CheckUserData(pos)
if v, ok := ud.Value.(*event.MessageMetadata); ok {
return v
}
ls.ArgError(1, messageMetadataName+" expected")
return nil
}
// Gets a field value from MessageMetadata user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func messageMetadataIndex(ls *lua.LState) int {
m := checkMessageMetadata(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "mailbox":
ls.Push(lua.LString(m.Mailbox))
case "id":
ls.Push(lua.LString(m.ID))
case "from":
ls.Push(wrapMailAddress(ls, m.From))
case "to":
lt := &lua.LTable{}
for _, v := range m.To {
lt.Append(wrapMailAddress(ls, v))
}
ls.Push(lt)
case "date":
ls.Push(lua.LNumber(m.Date.Unix()))
case "subject":
ls.Push(lua.LString(m.Subject))
case "size":
ls.Push(lua.LNumber(m.Size))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// Sets a field value on MessageMetadata user object. This emulates a Lua table,
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
func messageMetadataNewIndex(ls *lua.LState) int {
m := checkMessageMetadata(ls, 1)
index := ls.CheckString(2)
switch index {
case "mailbox":
m.Mailbox = ls.CheckString(3)
case "id":
m.ID = ls.CheckString(3)
case "from":
m.From = checkMailAddress(ls, 3)
case "to":
lt := ls.CheckTable(3)
to := make([]*mail.Address, 0, 16)
lt.ForEach(func(k, lv lua.LValue) {
if ud, ok := lv.(*lua.LUserData); ok {
if entry, ok := unwrapMailAddress(ud); ok {
to = append(to, entry)
}
}
})
m.To = to
case "date":
m.Date = time.Unix(ls.CheckInt64(3), 0)
case "subject":
m.Subject = ls.CheckString(3)
case "size":
m.Size = ls.CheckInt64(3)
default:
ls.RaiseError("invalid index %q", index)
}
return 0
}
@@ -0,0 +1,91 @@
package luahost
import (
"net/mail"
"testing"
"time"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestMessageMetadataGetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
function assert_eq(got, want)
assert(got == want, string.format("got name %q, wanted %q", got, want))
end
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42)
assert_eq(msg.from.name, "name1")
assert_eq(msg.from.address, "addr1")
assert_eq(table.getn(msg.to), 1)
assert_eq(msg.to[1].name, "name2")
assert_eq(msg.to[1].address, "addr2")
assert_eq(msg.date, 981173106)
`
ls := lua.NewState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestMessageMetadataSetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
msg.mailbox = "mb1"
msg.id = "id1"
msg.subject = "subj1"
msg.size = 42
msg.from = address.new("name1", "addr1")
msg.to = { address.new("name2", "addr2") }
msg.date = 981173106
`
got := &event.MessageMetadata{}
ls := lua.NewState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))
require.NoError(t, ls.DoString(script))
// Timezones will cause a naive comparison to fail.
assert.Equal(t, want.Date.Unix(), got.Date.Unix())
now := time.Now()
want.Date = now
got.Date = now
assert.Equal(t, want, got)
}
+17
View File
@@ -0,0 +1,17 @@
package luahost
import (
lua "github.com/yuin/gopher-lua"
)
const policyName = "policy"
func registerPolicyType(ls *lua.LState) {
mt := ls.NewTypeMetatable(policyName)
ls.SetGlobal(policyName, mt)
// Static attributes.
ls.SetField(mt, "allow", lua.LTrue)
ls.SetField(mt, "deny", lua.LFalse)
ls.SetField(mt, "defer", lua.LNil)
}
+195
View File
@@ -0,0 +1,195 @@
package luahost
import (
"bufio"
"fmt"
"io"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
// Host of Lua extensions.
type Host struct {
extHost *extension.Host
pool *statePool
logContext zerolog.Context
}
// New constructs a new Lua Host, pre-compiling the source.
func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
scriptPath := conf.Path
if scriptPath == "" {
return nil, nil
}
logContext := log.With().Str("module", "lua")
logger := logContext.Str("phase", "startup").Str("path", scriptPath).Logger()
// Pre-load, parse, and compile script.
if fi, err := os.Stat(scriptPath); err != nil {
logger.Info().Msg("Script file not found")
return nil, nil
} else if fi.IsDir() {
return nil, fmt.Errorf("Lua script %v is a directory", scriptPath)
}
logger.Info().Msg("Loading script")
file, err := os.Open(scriptPath)
defer file.Close()
if err != nil {
return nil, err
}
return NewFromReader(extHost, bufio.NewReader(file), scriptPath)
}
// NewFromReader constructs a new Lua Host, loading Lua source from the provided reader.
// The provided path is used in logging and error messages.
func NewFromReader(extHost *extension.Host, r io.Reader, path string) (*Host, error) {
logContext := log.With().Str("module", "lua")
logger := logContext.Str("phase", "startup").Str("path", path).Logger()
// Pre-parse, and compile script.
chunk, err := parse.Parse(r, path)
if err != nil {
return nil, err
}
proto, err := lua.Compile(chunk, path)
if err != nil {
return nil, err
}
// Build the pool and confirm LState is retrievable.
pool := newStatePool(proto)
h := &Host{extHost: extHost, pool: pool, logContext: logContext}
if ls, err := pool.getState(); err == nil {
h.wireFunctions(logger, ls)
// State creation works, put it back.
pool.putState(ls)
} else {
return nil, err
}
return h, nil
}
// CreateChannel creates a channel and places it into the named global variable
// in newly created LStates.
func (h *Host) CreateChannel(name string) chan lua.LValue {
return h.pool.createChannel(name)
}
// Detects global lua event listener functions and wires them up.
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
ib, err := getInbucket(ls)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to get inbucket global")
}
events := h.extHost.Events
const listenerName string = "lua"
if ib.After.MessageDeleted != nil {
events.AfterMessageDeleted.AddListener(listenerName, h.handleAfterMessageDeleted)
}
if ib.After.MessageStored != nil {
events.AfterMessageStored.AddListener(listenerName, h.handleAfterMessageStored)
}
if ib.Before.MailAccepted != nil {
events.BeforeMailAccepted.AddListener(listenerName, h.handleBeforeMailAccepted)
}
}
func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) {
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_deleted")
if !ok {
return
}
defer h.pool.putState(ls)
// Call lua function.
logger.Debug().Msgf("Calling Lua function with %+v", msg)
if err := ls.CallByParam(
lua.P{Fn: ib.After.MessageDeleted, NRet: 0, Protect: true},
wrapMessageMetadata(ls, &msg),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
}
}
func (h *Host) handleAfterMessageStored(msg event.MessageMetadata) {
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
if !ok {
return
}
defer h.pool.putState(ls)
// Call lua function.
logger.Debug().Msgf("Calling Lua function with %+v", msg)
if err := ls.CallByParam(
lua.P{Fn: ib.After.MessageStored, NRet: 0, Protect: true},
wrapMessageMetadata(ls, &msg),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
}
}
func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *bool {
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
if !ok {
return nil
}
defer h.pool.putState(ls)
logger.Debug().Msgf("Calling Lua function with %+v", addr)
if err := ls.CallByParam(
lua.P{Fn: ib.Before.MailAccepted, NRet: 1, Protect: true},
lua.LString(addr.Local),
lua.LString(addr.Domain),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
return nil
}
lval := ls.Get(1)
ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
if lval.Type() == lua.LTNil {
return nil
}
result := true
if lua.LVIsFalse(lval) {
result = false
}
return &result
}
// Common preparation for calling Lua functions.
func (h *Host) prepareInbucketFuncCall(funcName string) (logger zerolog.Logger, ls *lua.LState, ib *Inbucket, ok bool) {
logger = h.logContext.Str("event", funcName).Logger()
ls, err := h.pool.getState()
if err != nil {
logger.Error().Err(err).Msg("Failed to get Lua state instance from pool")
return logger, nil, nil, false
}
ib, err = getInbucket(ls)
if err != nil {
logger.Error().Err(err).Msg("Failed to obtain Lua inbucket object")
return logger, nil, nil, false
}
return logger, ls, ib, true
}
+166
View File
@@ -0,0 +1,166 @@
package luahost_test
import (
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/extension/luahost"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
async = false
test_ok = true
-- Sends marks tests failed instead of erroring when enabled.
function assert_async(value, message)
if not value then
if async then
print(message)
test_ok = false
else
error(message)
end
end
end
-- Tests plain values and list-style tables.
function assert_eq(got, want)
if type(got) == "table" and type(want) == "table" then
assert_async(#got == #want, string.format("got %d elements, wanted %d", #got, #want))
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
end
return
end
assert_async(got == want, string.format("got %q, wanted %q", got, want))
end
function assert_contains(got, want)
assert_async(string.find(got, want),
string.format("got %q, wanted it to contain %q", got, want))
end
`
func TestEmptyScript(t *testing.T) {
script := ""
extHost := extension.NewHost()
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
}
func TestAfterMessageDeleted(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_deleted(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageDeleted.Emit(msg)
assertNotified(t, notify)
}
func TestAfterMessageStored(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_stored(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageStored.Emit(msg)
assertNotified(t, notify)
}
func TestBeforeMailAccepted(t *testing.T) {
// Register lua event listener.
script := `
function inbucket.before.mail_accepted(localpart, domain)
return localpart == "from" and domain == "test"
end
`
extHost := extension.NewHost()
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
// Send event to be accepted.
addr := &event.AddressParts{Local: "from", Domain: "test"}
got := extHost.Events.BeforeMailAccepted.Emit(addr)
want := true
require.NotNil(t, got)
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
}
// Send event to be denied.
addr = &event.AddressParts{Local: "reject", Domain: "me"}
got = extHost.Events.BeforeMailAccepted.Emit(addr)
want = false
require.NotNil(t, got)
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
}
}
func assertNotified(t *testing.T, notify chan lua.LValue) {
t.Helper()
select {
case reslv := <-notify:
// Lua function received event.
if lua.LVIsFalse(reslv) {
t.Error("Lua responsed with false, wanted true")
}
case <-time.After(2 * time.Second):
t.Fatal("Lua did not respond to event within timeout")
}
}
+104
View File
@@ -0,0 +1,104 @@
package luahost
import (
"net/http"
"sync"
"github.com/cjoudrey/gluahttp"
"github.com/inbucket/gopher-json"
lua "github.com/yuin/gopher-lua"
)
type statePool struct {
sync.Mutex
funcProto *lua.FunctionProto // Compiled lua.
states []*lua.LState // Pool of available LStates.
channels map[string]chan lua.LValue // Global interop channels.
}
func newStatePool(funcProto *lua.FunctionProto) *statePool {
return &statePool{
funcProto: funcProto,
channels: make(map[string]chan lua.LValue),
}
}
// newState creates a new LState and configures it. Lock must be held.
func (lp *statePool) newState() (*lua.LState, error) {
ls := lua.NewState()
// Load supplemental native modules.
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
ls.PreloadModule("json", json.Loader)
// Setup channels.
for name, ch := range lp.channels {
ls.SetGlobal(name, lua.LChannel(ch))
}
// Register custom types.
registerInbucketTypes(ls)
registerMessageMetadataType(ls)
registerMailAddressType(ls)
registerPolicyType(ls)
// Run compiled script.
ls.Push(ls.NewFunctionFromProto(lp.funcProto))
if err := ls.PCall(0, lua.MultRet, nil); err != nil {
return nil, err
}
return ls, nil
}
// getState returns a free LState, or creates a new one.
func (lp *statePool) getState() (*lua.LState, error) {
lp.Lock()
defer lp.Unlock()
ln := len(lp.states)
if ln == 0 {
return lp.newState()
}
state := lp.states[ln-1]
lp.states = lp.states[0 : ln-1]
return state, nil
}
// putState returns the LState to the pool.
func (lp *statePool) putState(state *lua.LState) {
if state.IsClosed() {
return
}
// Clear stack.
state.Pop(state.GetTop())
lp.Lock()
defer lp.Unlock()
lp.states = append(lp.states, state)
}
// createChannel creates a new channel, which will become a global variable in
// newly created LStates. We also destroy any pooled states.
//
// Warning: There may still be checked out LStates that will not have the value
// set, which could be put back into the pool.
func (lp *statePool) createChannel(name string) chan lua.LValue {
lp.Lock()
defer lp.Unlock()
ch := make(chan lua.LValue, 10)
lp.channels[name] = ch
// Flush state pool.
for _, s := range lp.states {
s.Close()
}
lp.states = lp.states[:0]
return ch
}
+101
View File
@@ -0,0 +1,101 @@
package luahost
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
func makeEmptyPool() *statePool {
source := strings.NewReader("-- Empty source")
chunk, err := parse.Parse(source, "from string")
if err != nil {
panic(err)
}
proto, err := lua.Compile(chunk, "from string")
if err != nil {
panic(err)
}
return newStatePool(proto)
}
func TestPoolGetsDistinct(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
b, err := pool.getState()
require.NoError(t, err)
if a == b {
t.Error("Got pool a == b, expected distinct pools")
}
}
func TestPoolGrowsWithPuts(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
b, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
pool.putState(a)
pool.putState(b)
want := 2
if got := len(pool.states); got != want {
t.Errorf("len pool.states got %v, want %v", got, want)
}
}
// Closed LStates should not be added to the pool.
func TestPoolPutDiscardsClosed(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
a.Close()
pool.putState(a)
assert.Equal(t, 0, len(pool.states), "Wanted pool to remain empty")
}
func TestPoolPutClearsStack(t *testing.T) {
pool := makeEmptyPool()
ls, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
// Setup stack.
ls.Push(lua.LNumber(4))
ls.Push(lua.LString("bacon"))
require.Equal(t, 2, ls.GetTop(), "Want stack to have two items")
// Return and verify stack cleared.
pool.putState(ls)
assert.Equal(t, 1, len(pool.states), "Wanted pool to have one item")
require.Equal(t, 0, ls.GetTop(), "Want stack to be empty")
}
func TestPoolSetsChannels(t *testing.T) {
pool := makeEmptyPool()
pool.createChannel("test_chan")
s, err := pool.getState()
require.NoError(t, err)
got := s.GetGlobal("test_chan")
assert.Equal(t, lua.LTChannel, got.Type(),
"Got global type %v, wanted LTChannel", got.Type().String())
}
+20 -26
View File
@@ -7,10 +7,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/inbucket/inbucket/pkg/msghub" "github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -24,7 +24,7 @@ type Manager interface {
prefix string, prefix string,
content []byte, content []byte,
) (id string, err error) ) (id string, err error)
GetMetadata(mailbox string) ([]*Metadata, error) GetMetadata(mailbox string) ([]*event.MessageMetadata, error)
GetMessage(mailbox, id string) (*Message, error) GetMessage(mailbox, id string) (*Message, error)
MarkSeen(mailbox, id string) error MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error PurgeMessages(mailbox string) error
@@ -37,7 +37,7 @@ type Manager interface {
type StoreManager struct { type StoreManager struct {
AddrPolicy *policy.Addressing AddrPolicy *policy.Addressing
Store storage.Store Store storage.Store
Hub *msghub.Hub ExtHost *extension.Host
} }
// Deliver submits a new message to the store. // Deliver submits a new message to the store.
@@ -65,9 +65,10 @@ func (s *StoreManager) Deliver(
toaddr[i] = &torecip.Address toaddr[i] = &torecip.Address
} }
} }
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message") log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
delivery := &Delivery{ delivery := &Delivery{
Meta: Metadata{ Meta: event.MessageMetadata{
Mailbox: to.Mailbox, Mailbox: to.Mailbox,
From: fromaddr[0], From: fromaddr[0],
To: toaddr, To: toaddr,
@@ -80,31 +81,24 @@ func (s *StoreManager) Deliver(
if err != nil { if err != nil {
return "", err return "", err
} }
if s.Hub != nil {
// Broadcast message information. // Emit message stored event.
broadcast := msghub.Message{ event := delivery.Meta
Mailbox: to.Mailbox, event.ID = id
ID: id, s.ExtHost.Events.AfterMessageStored.Emit(&event)
From: stringutil.StringAddress(delivery.From()),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),
Size: delivery.Size(),
}
s.Hub.Dispatch(broadcast)
}
return id, nil return id, nil
} }
// GetMetadata returns a slice of metadata for the specified mailbox. // GetMetadata returns a slice of metadata for the specified mailbox.
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) { func (s *StoreManager) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
messages, err := s.Store.GetMessages(mailbox) messages, err := s.Store.GetMessages(mailbox)
if err != nil { if err != nil {
return nil, err return nil, err
} }
metas := make([]*Metadata, len(messages)) metas := make([]*event.MessageMetadata, len(messages))
for i, sm := range messages { for i, sm := range messages {
metas[i] = makeMetadata(sm) metas[i] = MakeMetadata(sm)
} }
return metas, nil return metas, nil
} }
@@ -124,8 +118,8 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
return nil, err return nil, err
} }
_ = r.Close() _ = r.Close()
header := makeMetadata(sm) header := MakeMetadata(sm)
return &Message{Metadata: *header, env: env}, nil return &Message{MessageMetadata: *header, env: env}, nil
} }
// MarkSeen marks the message as having been read. // MarkSeen marks the message as having been read.
@@ -159,9 +153,9 @@ func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
return s.AddrPolicy.ExtractMailbox(mailbox) return s.AddrPolicy.ExtractMailbox(mailbox)
} }
// makeMetadata populates Metadata from a storage.Message. // MakeMetadata populates Metadata from a storage.Message.
func makeMetadata(m storage.Message) *Metadata { func MakeMetadata(m storage.Message) *event.MessageMetadata {
return &Metadata{ return &event.MessageMetadata{
Mailbox: m.Mailbox(), Mailbox: m.Mailbox(),
ID: m.ID(), ID: m.ID(),
From: m.From(), From: m.From(),
+38
View File
@@ -0,0 +1,38 @@
package message_test
import (
"testing"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestManagerEmitsMessageStoredEvent(t *testing.T) {
extHost := extension.NewHost()
sm := &message.StoreManager{
AddrPolicy: &policy.Addressing{},
Store: test.NewStore(),
ExtHost: extHost,
}
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
// Attempt to deliver a message to generate event.
if _, err := sm.Deliver(
&policy.Recipient{},
"from@example.com",
[]*policy.Recipient{},
"prefix",
[]byte("From: from@example.com\n\ntest email"),
); err != nil {
t.Fatal(err)
}
got, err := listener()
require.NoError(t, err)
assert.NotNil(t, got, "No event received, or it was nil")
}
+6 -17
View File
@@ -8,33 +8,22 @@ import (
"net/textproto" "net/textproto"
"time" "time"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
) )
// Metadata holds information about a message, but not the content.
type Metadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}
// Message holds both the metadata and content of a message. // Message holds both the metadata and content of a message.
type Message struct { type Message struct {
Metadata event.MessageMetadata
env *enmime.Envelope env *enmime.Envelope
} }
// New constructs a new Message // New constructs a new Message
func New(m Metadata, e *enmime.Envelope) *Message { func New(m event.MessageMetadata, e *enmime.Envelope) *Message {
return &Message{ return &Message{
Metadata: m, MessageMetadata: m,
env: e, env: e,
} }
} }
@@ -65,7 +54,7 @@ func (m *Message) Text() string {
// Delivery is used to add a message to storage. // Delivery is used to add a message to storage.
type Delivery struct { type Delivery struct {
Meta Metadata Meta event.MessageMetadata
Reader io.Reader Reader io.Reader
} }
+82 -31
View File
@@ -3,26 +3,19 @@ package msghub
import ( import (
"container/ring" "container/ring"
"context" "context"
"time"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/rs/zerolog/log"
) )
// Length of msghub operation queue // Length of msghub operation queue
const opChanLen = 100 const opChanLen = 100
// Message contains the basic header data for a message
type Message struct {
Mailbox string
ID string
From string
To []string
Subject string
Date time.Time
Size int64
}
// Listener receives the contents of the history buffer, followed by new messages // Listener receives the contents of the history buffer, followed by new messages
type Listener interface { type Listener interface {
Receive(msg Message) error Receive(msg event.MessageMetadata) error
Delete(mailbox string, id string) error
} }
// Hub relays messages on to its listeners // Hub relays messages on to its listeners
@@ -36,38 +29,51 @@ type Hub struct {
// New constructs a new Hub which will cache historyLen messages in memory for playback to future // New constructs a new Hub which will cache historyLen messages in memory for playback to future
// listeners. A goroutine is created to handle incoming messages; it will run until the provided // listeners. A goroutine is created to handle incoming messages; it will run until the provided
// context is canceled. // context is canceled.
func New(ctx context.Context, historyLen int) *Hub { func New(historyLen int, extHost *extension.Host) *Hub {
h := &Hub{ hub := &Hub{
history: ring.New(historyLen), history: ring.New(historyLen),
listeners: make(map[Listener]struct{}), listeners: make(map[Listener]struct{}),
opChan: make(chan func(h *Hub), opChanLen), opChan: make(chan func(h *Hub), opChanLen),
} }
go func() { // Register an extension event listener for MessageStored.
for { extHost.Events.AfterMessageStored.AddListener("msghub",
select { func(msg event.MessageMetadata) {
case <-ctx.Done(): hub.Dispatch(msg)
// Shutdown })
close(h.opChan)
return
case op := <-h.opChan:
op(h)
}
}
}()
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 // Dispatch queues a message for broadcast by the hub. The message will be placed into the
// history buffer and then relayed to all registered listeners. // history buffer and then relayed to all registered listeners.
func (hub *Hub) Dispatch(msg Message) { func (hub *Hub) Dispatch(msg event.MessageMetadata) {
hub.opChan <- func(h *Hub) { hub.opChan <- func(h *Hub) {
if h.history != nil { if h.history != nil {
// Add to history buffer // Add to history buffer
h.history.Value = msg h.history.Value = msg
h.history = h.history.Next() h.history = h.history.Next()
// Deliver message to all listeners, removing listeners if they return an error
// Relay event to all listeners, removing listeners if they return an error.
for l := range h.listeners { for l := range h.listeners {
if err := l.Receive(msg); err != nil { if err := l.Receive(msg); err != nil {
delete(h.listeners, l) delete(h.listeners, l)
@@ -77,13 +83,44 @@ func (hub *Hub) Dispatch(msg Message) {
} }
} }
// Delete removes the message from the history buffer and instructs listeners to do the same.
func (hub *Hub) Delete(mailbox string, id string) {
hub.opChan <- func(h *Hub) {
if h.history == nil {
return
}
// Locate and remove history entry.
p := h.history
end := p
for {
if next, ok := p.Next().Value.(event.MessageMetadata); ok {
if mailbox == next.Mailbox && id == next.ID {
p.Next().Value = nil
break
}
}
if p = p.Next(); p == end {
break
}
}
// Relay event to all listeners, removing listeners if they return an error.
for l := range h.listeners {
if err := l.Delete(mailbox, id); err != nil {
delete(h.listeners, l)
}
}
}
}
// AddListener registers a listener to receive broadcasted messages. // AddListener registers a listener to receive broadcasted messages.
func (hub *Hub) AddListener(l Listener) { func (hub *Hub) AddListener(l Listener) {
hub.opChan <- func(h *Hub) { hub.opChan <- func(h *Hub) {
// Playback log // Playback log
h.history.Do(func(v interface{}) { h.history.Do(func(v interface{}) {
if v != nil { if v != nil {
l.Receive(v.(Message)) l.Receive(v.(event.MessageMetadata))
} }
}) })
@@ -108,3 +145,17 @@ func (hub *Hub) Sync() {
} }
<-done <-done
} }
func (hub *Hub) runOp(op func(*Hub)) {
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok {
log.Error().Str("module", "msghub").Err(err).Msg("Operation panicked")
} else {
log.Error().Str("module", "msghub").Err(err).Msgf("Operation panicked: %s", r)
}
}
}()
op(hub)
}
+159 -35
View File
@@ -3,15 +3,23 @@ package msghub
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"testing" "testing"
"time" "time"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// testListener implements the Listener interface, mock for unit tests // testListener implements the Listener interface, mock for unit tests
type testListener struct { type testListener struct {
messages []*Message // received messages messages []*event.MessageMetadata // received messages
wantMessages int // how many messages this listener wants to receive deletes []string // received deletes
errorAfter int // when != 0, messages until Receive() begins returning error wantEvents int // how many events this listener wants to receive
errorAfter int // when != 0, event count until Receive() begins returning error
gotEvents int
done chan struct{} // closed once we have received wantMessages done chan struct{} // closed once we have received wantMessages
overflow chan struct{} // closed if we receive wantMessages+1 overflow chan struct{} // closed if we receive wantMessages+1
@@ -19,10 +27,11 @@ type testListener struct {
func newTestListener(want int) *testListener { func newTestListener(want int) *testListener {
l := &testListener{ l := &testListener{
messages: make([]*Message, 0, want*2), messages: make([]*event.MessageMetadata, 0, want*2),
wantMessages: want, deletes: make([]string, 0, want*2),
done: make(chan struct{}), wantEvents: want,
overflow: make(chan struct{}), done: make(chan struct{}),
overflow: make(chan struct{}),
} }
if want == 0 { if want == 0 {
close(l.done) close(l.done)
@@ -32,29 +41,34 @@ func newTestListener(want int) *testListener {
// Receive a Message, store it in the messages slice, close applicable channels, and return an error // Receive a Message, store it in the messages slice, close applicable channels, and return an error
// if instructed // if instructed
func (l *testListener) Receive(msg Message) error { func (l *testListener) Receive(msg event.MessageMetadata) error {
l.gotEvents++
l.messages = append(l.messages, &msg) l.messages = append(l.messages, &msg)
if len(l.messages) == l.wantMessages { if l.gotEvents == l.wantEvents {
close(l.done) close(l.done)
} }
if len(l.messages) == l.wantMessages+1 { if l.gotEvents == l.wantEvents+1 {
close(l.overflow) close(l.overflow)
} }
if l.errorAfter > 0 && len(l.messages) > l.errorAfter { if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
return fmt.Errorf("Too many messages") return fmt.Errorf("Too many messages")
} }
return nil return nil
} }
func (l *testListener) Delete(mailbox string, id string) error {
l.gotEvents++
l.deletes = append(l.deletes, mailbox+"/"+id)
return nil
}
// String formats the got vs wanted message counts // String formats the got vs wanted message counts
func (l *testListener) String() string { func (l *testListener) String() string {
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages) return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantEvents)
} }
func TestHubNew(t *testing.T) { func TestHubNew(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) hub := New(5, extension.NewHost())
defer cancel()
hub := New(ctx, 5)
if hub == nil { if hub == nil {
t.Fatal("New() == nil, expected a new Hub") t.Fatal("New() == nil, expected a new Hub")
} }
@@ -63,30 +77,33 @@ func TestHubNew(t *testing.T) {
func TestHubZeroLen(t *testing.T) { func TestHubZeroLen(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 0) hub := New(0, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
hub.Dispatch(m) hub.Dispatch(m)
} }
// Just making sure Hub doesn't panic // Ensures Hub doesn't panic
} }
func TestHubZeroListeners(t *testing.T) { func TestHubZeroListeners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 5) hub := New(5, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
hub.Dispatch(m) hub.Dispatch(m)
} }
// Just making sure Hub doesn't panic // Ensures Hub doesn't panic
} }
func TestHubOneListener(t *testing.T) { func TestHubOneListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 5) hub := New(5, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1) l := newTestListener(1)
hub.AddListener(l) hub.AddListener(l)
@@ -103,8 +120,9 @@ func TestHubOneListener(t *testing.T) {
func TestHubRemoveListener(t *testing.T) { func TestHubRemoveListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 5) hub := New(5, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1) l := newTestListener(1)
hub.AddListener(l) hub.AddListener(l)
@@ -125,8 +143,9 @@ func TestHubRemoveListener(t *testing.T) {
func TestHubRemoveListenerOnError(t *testing.T) { func TestHubRemoveListenerOnError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 5) hub := New(5, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
// error after 1 means listener should receive 2 messages before being removed // error after 1 means listener should receive 2 messages before being removed
l := newTestListener(2) l := newTestListener(2)
@@ -151,14 +170,15 @@ func TestHubRemoveListenerOnError(t *testing.T) {
func TestHubHistoryReplay(t *testing.T) { func TestHubHistoryReplay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 100) hub := New(100, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(3) l1 := newTestListener(3)
hub.AddListener(l1) hub.AddListener(l1)
// Broadcast 3 messages with no listeners // Broadcast 3 messages with no listeners
msgs := make([]Message, 3) msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ { for i := 0; i < len(msgs); i++ {
msgs[i] = Message{ msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i), Subject: fmt.Sprintf("subj %v", i),
} }
hub.Dispatch(msgs[i]) hub.Dispatch(msgs[i])
@@ -191,17 +211,67 @@ func TestHubHistoryReplay(t *testing.T) {
} }
} }
func TestHubHistoryDelete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(100, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(3)
hub.AddListener(l1)
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ {
msgs[i] = event.MessageMetadata{
Mailbox: "hub",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
hub.Delete("hub", "1") // Delete a message
hub.Delete("zzz", "0") // Attempt to delete non-existent mailbox message
// Add a new listener, waits for 2 messages
l2 := newTestListener(2)
hub.AddListener(l2)
// Wait for messages (history)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
want := []string{"subj 0", "subj 2"}
for i := 0; i < len(want); i++ {
got := l2.messages[i].Subject
if got != want[i] {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
}
}
}
func TestHubHistoryReplayWrap(t *testing.T) { func TestHubHistoryReplayWrap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
hub := New(ctx, 5) hub := New(5, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(20) l1 := newTestListener(20)
hub.AddListener(l1) hub.AddListener(l1)
// Broadcast more messages than the hub can hold // Broadcast more messages than the hub can hold
msgs := make([]Message, 20) msgs := make([]event.MessageMetadata, 20)
for i := 0; i < len(msgs); i++ { for i := 0; i < len(msgs); i++ {
msgs[i] = Message{ msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i), Subject: fmt.Sprintf("subj %v", i),
} }
hub.Dispatch(msgs[i]) hub.Dispatch(msgs[i])
@@ -234,10 +304,64 @@ func TestHubHistoryReplayWrap(t *testing.T) {
} }
} }
func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
bufferSize := 5
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(bufferSize, extension.NewHost())
go hub.Start(ctx)
waitForMessages := func(n int) {
l := newTestListener(n)
hub.AddListener(l)
select {
case <-l.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l)
}
}
// Broadcast more messages than the hub can hold.
msgs := make([]event.MessageMetadata, 10)
for i := 0; i < len(msgs); i++ {
msgs[i] = event.MessageMetadata{
Mailbox: "first",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
waitForMessages(bufferSize)
// Buffer must be configured size.
require.Equal(t, bufferSize, hub.history.Len())
// Delete a message still present in buffer.
hub.Delete("first", "7")
// Broadcast another set of messages.
for i := 0; i < len(msgs); i++ {
msgs[i] = event.MessageMetadata{
Mailbox: "second",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
waitForMessages(bufferSize)
// Ensure the buffer did not shrink after delete.
got := hub.history.Len()
assert.Equal(t, bufferSize, got, "got buffer size %d, wanted %d", got, bufferSize)
}
func TestHubContextCancel(t *testing.T) { func TestHubContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
hub := New(ctx, 5) hub := New(5, extension.NewHost())
m := Message{} go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1) l := newTestListener(1)
hub.AddListener(l) hub.AddListener(l)
+74 -24
View File
@@ -3,6 +3,7 @@ package policy
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net"
"net/mail" "net/mail"
"strings" "strings"
@@ -17,44 +18,41 @@ type Addressing struct {
// ExtractMailbox extracts the mailbox name from a partial email address. // ExtractMailbox extracts the mailbox name from a partial email address.
func (a *Addressing) ExtractMailbox(address string) (string, error) { func (a *Addressing) ExtractMailbox(address string) (string, error) {
if a.Config.MailboxNaming == config.DomainNaming {
return extractDomainMailbox(address)
}
local, domain, err := parseEmailAddress(address) local, domain, err := parseEmailAddress(address)
if err != nil { if err != nil {
return "", err return "", err
} }
local, err = parseMailboxName(local) local, err = parseMailboxName(local)
if err != nil { if err != nil {
return "", err return "", err
} }
if a.Config.MailboxNaming == config.LocalNaming { if a.Config.MailboxNaming == config.LocalNaming {
return local, nil return local, nil
} }
if a.Config.MailboxNaming == config.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 { if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming) return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
} }
if domain == "" { if domain == "" {
return local, nil return local, nil
} }
if !ValidateDomainPart(domain) { if !ValidateDomainPart(domain) {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address) return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
} }
return local + "@" + domain, nil return local + "@" + domain, nil
} }
// NewRecipient parses an address into a Recipient. // NewRecipient parses an address into a Recipient. This is used for parsing RCPT TO arguments,
// not To headers.
func (a *Addressing) NewRecipient(address string) (*Recipient, error) { func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
local, domain, err := ParseEmailAddress(address) local, domain, err := ParseEmailAddress(address)
if err != nil { if err != nil {
@@ -64,12 +62,8 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
ar, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
return &Recipient{ return &Recipient{
Address: *ar, Address: mail.Address{Address: address},
addrPolicy: a, addrPolicy: a,
LocalPart: local, LocalPart: local,
Domain: domain, Domain: domain,
@@ -122,13 +116,24 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by // ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
// ParseEmailAddress(). // ParseEmailAddress().
func ValidateDomainPart(domain string) bool { func ValidateDomainPart(domain string) bool {
if len(domain) == 0 { ln := len(domain)
if ln == 0 {
return false return false
} }
if len(domain) > 255 { if ln > 255 {
return false return false
} }
if domain[len(domain)-1] != '.' { if ln >= 4 && domain[0] == '[' && domain[ln-1] == ']' {
// Bracketed domains must contain an IP address.
s := 1
if strings.HasPrefix(domain[1:], "IPv6:") {
s = 6
}
ip := net.ParseIP(domain[s : ln-1])
return ip != nil
}
if domain[ln-1] != '.' {
domain += "." domain += "."
} }
prev := '.' prev := '.'
@@ -168,6 +173,40 @@ func ValidateDomainPart(domain string) bool {
return true return true
} }
// Extracts the mailbox name when domain addressing is enabled.
func extractDomainMailbox(address string) (string, error) {
var local, domain string
var err error
if address != "" && address[0] == '[' && address[len(address)-1] == ']' {
// Likely an IP address in brackets, treat as domain only.
domain = address
} else {
local, domain, err = parseEmailAddress(address)
if err != nil {
return "", err
}
}
if local != "" {
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
}
// If no @domain is specified, assume this is being used for mailbox lookup via the API.
if domain == "" {
domain = local
}
if ValidateDomainPart(domain) == false {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
}
return domain, nil
}
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An // parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
// error is returned if the local part fails validation following the guidelines in RFC3696. The // error is returned if the local part fails validation following the guidelines in RFC3696. The
// domain part is optional and not validated. // domain part is optional and not validated.
@@ -178,12 +217,23 @@ func parseEmailAddress(address string) (local string, domain string, err error)
if len(address) > 320 { if len(address) > 320 {
return "", "", fmt.Errorf("address exceeds 320 characters") return "", "", fmt.Errorf("address exceeds 320 characters")
} }
// Remove forward-path routes.
if address[0] == '@' { if address[0] == '@' {
return "", "", fmt.Errorf("address cannot start with @ symbol") end := strings.IndexRune(address, ':')
if end == -1 {
return "", "", fmt.Errorf("missing terminating ':' in route specification")
}
address = address[end+1:]
if address == "" {
return "", "", fmt.Errorf("Address empty after removing route specification")
}
} }
if address[0] == '.' { if address[0] == '.' {
return "", "", fmt.Errorf("address cannot start with a period") return "", "", fmt.Errorf("address cannot start with a period")
} }
// Loop over address parsing out local part. // Loop over address parsing out local part.
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
prev := byte('.') prev := byte('.')
+95
View File
@@ -259,6 +259,30 @@ func TestExtractMailboxValid(t *testing.T) {
full: "chars|}~@example.co.uk", full: "chars|}~@example.co.uk",
domain: "example.co.uk", domain: "example.co.uk",
}, },
{
input: "@host:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "@a.com,@b.com:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "u@[127.0.0.1]",
local: "u",
full: "u@[127.0.0.1]",
domain: "[127.0.0.1]",
},
{
input: "u@[IPv6:2001:db8:aaaa:1::100]",
local: "u",
full: "u@[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
} }
for _, tc := range testTable { for _, tc := range testTable {
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil { if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
@@ -285,10 +309,42 @@ func TestExtractMailboxValid(t *testing.T) {
} }
} }
// Test special cases with domain addressing mode.
func TestExtractDomainMailboxValid(t *testing.T) {
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
tests := map[string]struct {
input string // Input to test
domain string // Expected output when mailbox naming = domain
}{
"ipv4": {
input: "[127.0.0.1]",
domain: "[127.0.0.1]",
},
"medium ipv6": {
input: "[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
} else {
if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
}
})
}
}
func TestExtractMailboxInvalid(t *testing.T) { func TestExtractMailboxInvalid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}} localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}} fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}} domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
// Test local mailbox naming policy. // Test local mailbox naming policy.
localInvalidTable := []struct { localInvalidTable := []struct {
input, msg string input, msg string
@@ -303,6 +359,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg) t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
} }
} }
// Test full mailbox naming policy. // Test full mailbox naming policy.
fullInvalidTable := []struct { fullInvalidTable := []struct {
input, msg string input, msg string
@@ -318,6 +375,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg) t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
} }
} }
// Test domain mailbox naming policy. // Test domain mailbox naming policy.
domainInvalidTable := []struct { domainInvalidTable := []struct {
input, msg string input, msg string
@@ -365,6 +423,10 @@ func TestValidateDomain(t *testing.T) {
{strings.Repeat("a", 256), false, "Max domain length is 255"}, {strings.Repeat("a", 256), false, "Max domain length is 255"},
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"}, {strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"}, {strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
{"[0.0.0.0]", true, "Single digit octet IP addr is valid"},
{"[123.123.123.123]", true, "Multiple digit octet IP addr is valid"},
{"[IPv6:2001:0db8:aaaa:0001:0000:0000:0000:0200]", true, "Full IPv6 addr is valid"},
{"[IPv6:::1]", true, "Abbr IPv6 addr is valid"},
} }
for _, tt := range testTable { for _, tt := range testTable {
if policy.ValidateDomainPart(tt.input) != tt.expect { if policy.ValidateDomainPart(tt.input) != tt.expect {
@@ -419,6 +481,9 @@ func TestValidateLocal(t *testing.T) {
{"$A12345", true, "RFC3696 test case should be valid"}, {"$A12345", true, "RFC3696 test case should be valid"},
{"!def!xyz%abc", true, "RFC3696 test case should be valid"}, {"!def!xyz%abc", true, "RFC3696 test case should be valid"},
{"_somename", true, "RFC3696 test case should be valid"}, {"_somename", true, "RFC3696 test case should be valid"},
{"@host:mailbox", true, "Forward-path routes are valid"},
{"@a.com,@b.com:mailbox", true, "Multi-hop forward-path routes are valid"},
{"@a.com,mailbox", false, "Unterminated forward-path routes are invalid"},
} }
for _, tt := range testTable { for _, tt := range testTable {
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com") _, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
@@ -430,3 +495,33 @@ func TestValidateLocal(t *testing.T) {
} }
} }
} }
// TestRecipientAddress verifies the Recipient.Address values returned by Addressing.NewRecipient.
// This function parses a RCPT TO path, not a To header. See rfc5321#section-4.1.2
func TestRecipientAddress(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
tests := map[string]string{
"common": "user@example.com",
"with label": "user+mailbox@example.com",
"special chars": "a!#$%&'*+-/=?^_`{|}~@example.com",
"ipv4": "user@[127.0.0.1]",
"ipv6": "user@[IPv6:::1]",
"route host": "@host:user@example.com",
"route domain": "@route.com:user@example.com",
"multi-hop route": "@first.com,@second.com:user@example.com",
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
r, err := localPolicy.NewRecipient(tc)
if err != nil {
t.Fatalf("Parse of %q failed: %v", tc, err)
}
if got, want := r.Address.Address, tc; got != want {
t.Errorf("Got Address: %q, want: %q", got, want)
}
})
}
}
+10 -9
View File
@@ -9,6 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/test" "github.com/inbucket/inbucket/pkg/test"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
@@ -67,7 +68,7 @@ func TestRestMailboxList(t *testing.T) {
// Test JSON message headers // Test JSON message headers
tzPDT := time.FixedZone("PDT", -7*3600) tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600) tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{ meta1 := event.MessageMetadata{
Mailbox: "good", Mailbox: "good",
ID: "0001", ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"}, From: &mail.Address{Name: "", Address: "from1@host"},
@@ -75,7 +76,7 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 1", Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST), Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
} }
meta2 := message.Metadata{ meta2 := event.MessageMetadata{
Mailbox: "good", Mailbox: "good",
ID: "0002", ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"}, From: &mail.Address{Name: "", Address: "from2@host"},
@@ -83,8 +84,8 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 2", Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT), Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
} }
mm.AddMessage("good", &message.Message{Metadata: meta1}) mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2}) mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
// Check return code // Check return code
w, err = testRestGet("http://localhost/api/v1/mailbox/good") w, err = testRestGet("http://localhost/api/v1/mailbox/good")
@@ -178,7 +179,7 @@ func TestRestMessage(t *testing.T) {
// Test JSON message headers // Test JSON message headers
tzPST := time.FixedZone("PST", -8*3600) tzPST := time.FixedZone("PST", -8*3600)
msg1 := message.New( msg1 := message.New(
message.Metadata{ event.MessageMetadata{
Mailbox: "good", Mailbox: "good",
ID: "0001", ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"}, From: &mail.Address{Name: "", Address: "from1@host"},
@@ -254,7 +255,7 @@ func TestRestMarkSeen(t *testing.T) {
// Create some messages. // Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600) tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600) tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{ meta1 := event.MessageMetadata{
Mailbox: "good", Mailbox: "good",
ID: "0001", ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"}, From: &mail.Address{Name: "", Address: "from1@host"},
@@ -262,7 +263,7 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 1", Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST), Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
} }
meta2 := message.Metadata{ meta2 := event.MessageMetadata{
Mailbox: "good", Mailbox: "good",
ID: "0002", ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"}, From: &mail.Address{Name: "", Address: "from2@host"},
@@ -270,8 +271,8 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 2", Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT), Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
} }
mm.AddMessage("good", &message.Message{Metadata: meta1}) mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2}) mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
// Mark one read. // Mark one read.
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`) w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
expectCode := 200 expectCode := 200
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"time" "time"
) )
// JSONMessageHeaderV1 contains the basic header data for a message // JSONMessageHeaderV1 contains the basic header data for a message.
type JSONMessageHeaderV1 struct { type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"` Mailbox string `json:"mailbox"`
ID string `json:"id"` ID string `json:"id"`
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
Seen bool `json:"seen"` Seen bool `json:"seen"`
} }
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody // JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
type JSONMessageV1 struct { type JSONMessageV1 struct {
Mailbox string `json:"mailbox"` Mailbox string `json:"mailbox"`
ID string `json:"id"` ID string `json:"id"`
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
Attachments []*JSONMessageAttachmentV1 `json:"attachments"` Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
} }
// JSONMessageAttachmentV1 contains information about a MIME attachment // JSONMessageAttachmentV1 contains information about a MIME attachment.
type JSONMessageAttachmentV1 struct { type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"` FileName string `json:"filename"`
ContentType string `json:"content-type"` ContentType string `json:"content-type"`
@@ -42,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
MD5 string `json:"md5"` MD5 string `json:"md5"`
} }
// JSONMessageBodyV1 contains the Text and HTML versions of the message body // JSONMessageBodyV1 contains the Text and HTML versions of the message body.
type JSONMessageBodyV1 struct { type JSONMessageBodyV1 struct {
Text string `json:"text"` Text string `json:"text"`
HTML string `json:"html"` HTML string `json:"html"`
+15
View 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"`
}
+6
View File
@@ -22,4 +22,10 @@ func SetupRoutes(r *mux.Router) {
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET") web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/v1/monitor/messages/{name}").Handler( r.Path("/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET") web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
// API v2
r.Path("/v2/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV2)).Name("MonitorAllMessagesV2").Methods("GET")
r.Path("/v2/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV2)).Name("MonitorMailboxMessagesV2").Methods("GET")
} }
+52 -41
View File
@@ -5,71 +5,79 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/msghub" "github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/rest/model" "github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web" "github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const ( const (
// Time allowed to write a message to the peer. // Time allowed to write a message to the peer.
writeWait = 10 * time.Second writeWaitV1 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait. // Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10 pingPeriodV1 = (pongWaitV1 * 9) / 10
// Time allowed to read the next pong message from the peer. // Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second pongWaitV1 = 60 * time.Second
// Maximum message size allowed from peer. // Maximum message size allowed from peer.
maxMessageSize = 512 maxMessageSizeV1 = 512
) )
// options for gorilla connection upgrader // options for gorilla connection upgrader
var upgrader = websocket.Upgrader{ var upgraderV1 = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
} }
// msgListener handles messages from the msghub // msgListenerV1 handles messages from the msghub
type msgListener struct { type msgListenerV1 struct {
hub *msghub.Hub // Global message hub hub *msghub.Hub // Global message hub
c chan msghub.Message // Queue of messages from Receive() c chan event.MessageMetadata // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes mailbox string // Name of mailbox to monitor, "" == all mailboxes
} }
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict // newMsgListenerV1 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only. // messages sent to WebSocket to that mailbox only.
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener { func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListenerV1 {
ml := &msgListener{ ml := &msgListenerV1{
hub: hub, hub: hub,
c: make(chan msghub.Message, 100), c: make(chan event.MessageMetadata, 100),
mailbox: mailbox, mailbox: mailbox,
} }
hub.AddListener(ml) hub.AddListener(ml)
return ml return ml
} }
// Receive handles an incoming message // Receive handles an incoming message.
func (ml *msgListener) Receive(msg msghub.Message) error { func (ml *msgListenerV1) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox { if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match mailbox name // Did not match the watched mailbox name.
return nil return nil
} }
ml.c <- msg ml.c <- msg
return nil return nil
} }
// Delete handles a deleted message.
func (ml *msgListenerV1) Delete(mailbox string, id string) error {
// Deletes are ignored in socketv1 API.
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client // WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListener) WSReader(conn *websocket.Conn) { func (ml *msgListenerV1) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket"). slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger() Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close() defer ml.Close()
conn.SetReadLimit(maxMessageSize) conn.SetReadLimit(maxMessageSizeV1)
conn.SetReadDeadline(time.Now().Add(pongWait)) conn.SetReadDeadline(time.Now().Add(pongWaitV1))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong") slog.Debug().Msg("Got pong")
conn.SetReadDeadline(time.Now().Add(pongWait)) conn.SetReadDeadline(time.Now().Add(pongWaitV1))
return nil return nil
}) })
@@ -92,8 +100,8 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
} }
// WSWriter makes sure the websocket client is still connected // WSWriter makes sure the websocket client is still connected
func (ml *msgListener) WSWriter(conn *websocket.Conn) { func (ml *msgListenerV1) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod) ticker := time.NewTicker(pingPeriodV1)
defer func() { defer func() {
ticker.Stop() ticker.Stop()
ml.Close() ml.Close()
@@ -103,29 +111,19 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
for { for {
select { select {
case msg, ok := <-ml.c: case msg, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWait)) conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
if !ok { if !ok {
// msgListener closed, exit // msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{}) conn.WriteMessage(websocket.CloseMessage, []byte{})
return return
} }
header := &model.JSONMessageHeaderV1{ if conn.WriteJSON(metadataToHeader(&msg)) != nil {
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
// Write failed // Write failed
return return
} }
case <-ticker.C: case <-ticker.C:
// Send ping // Send ping
conn.SetWriteDeadline(time.Now().Add(writeWait)) conn.SetWriteDeadline(time.Now().Add(writeWaitV1))
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil { if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error // Write error
return return
@@ -137,7 +135,7 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
} }
// Close removes the listener registration // Close removes the listener registration
func (ml *msgListener) Close() { func (ml *msgListenerV1) Close() {
select { select {
case <-ml.c: case <-ml.c:
// Already closed // Already closed
@@ -152,7 +150,7 @@ func (ml *msgListener) Close() {
func MonitorAllMessagesV1( func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket. // Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil) conn, err := upgraderV1.Upgrade(w, req, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -164,7 +162,7 @@ func MonitorAllMessagesV1(
log.Debug().Str("module", "rest").Str("proto", "WebSocket"). log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket") Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn. // Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, "") ml := newMsgListenerV1(ctx.MsgHub, "")
go ml.WSWriter(conn) go ml.WSWriter(conn)
ml.WSReader(conn) ml.WSReader(conn)
return nil return nil
@@ -179,7 +177,7 @@ func MonitorMailboxMessagesV1(
return err return err
} }
// Upgrade to Websocket. // Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil) conn, err := upgraderV1.Upgrade(w, req, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -191,8 +189,21 @@ func MonitorMailboxMessagesV1(
log.Debug().Str("module", "rest").Str("proto", "WebSocket"). log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket") Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn. // Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, name) ml := newMsgListenerV1(ctx.MsgHub, name)
go ml.WSWriter(conn) go ml.WSWriter(conn)
ml.WSReader(conn) ml.WSReader(conn)
return nil return nil
} }
func metadataToHeader(msg *event.MessageMetadata) *model.JSONMessageHeaderV1 {
return &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
}
+214
View File
@@ -0,0 +1,214 @@
package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/rs/zerolog/log"
)
const (
// Time allowed to write a message to the peer.
writeWaitV2 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriodV2 = (pongWaitV2 * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWaitV2 = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSizeV2 = 512
)
// options for gorilla connection upgrader
var upgraderV2 = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListenerV2 handles messages from the msghub
type msgListenerV2 struct {
hub *msghub.Hub // Global message hub.
c chan *model.JSONMonitorEventV2 // Queue of incoming events.
mailbox string // Name of mailbox to monitor, "" == all mailboxes.
}
// newMsgListenerV2 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListenerV2 {
ml := &msgListenerV2{
hub: hub,
c: make(chan *model.JSONMonitorEventV2, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message.
func (ml *msgListenerV2) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match the watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-stored",
Header: metadataToHeader(&msg),
}
return nil
}
// Delete handles a deleted message.
func (ml *msgListenerV2) Delete(mailbox string, id string) error {
if ml.mailbox != "" && ml.mailbox != mailbox {
// Did not match watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-deleted",
Identifier: &model.JSONMessageIDV2{
Mailbox: mailbox,
ID: id,
},
}
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListenerV2) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close()
conn.SetReadLimit(maxMessageSizeV2)
conn.SetReadDeadline(time.Now().Add(pongWaitV2))
conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
conn.SetReadDeadline(time.Now().Add(pongWaitV2))
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
slog.Warn().Err(err).Msg("Socket error")
} else {
slog.Debug().Msg("Closing socket")
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListenerV2) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriodV2)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case event, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWaitV2))
if !ok {
// msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if conn.WriteJSON(event) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
conn.SetWriteDeadline(time.Now().Add(writeWaitV2))
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListenerV2) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
}
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
// the client of all messages received.
func MonitorAllMessagesV2(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
// MonitorMailboxMessagesV2 is a web handler which upgrades the connection to a websocket and
// notifies the client of messages received by a particular mailbox.
func MonitorMailboxMessagesV2(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket.
conn, err := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
+1 -2
View File
@@ -48,9 +48,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
UIDir: "../ui", UIDir: "../ui",
}, },
} }
shutdownChan := make(chan bool)
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{}) web.NewServer(cfg, mm, &msghub.Hub{})
return buf return buf
} }
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"context"
"sync"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/luahost"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/server/pop3"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui"
)
// Services holds the configured services.
type Services struct {
MsgHub *msghub.Hub
POP3Server *pop3.Server
RetentionScanner *storage.RetentionScanner
SMTPServer *smtp.Server
WebServer *web.Server
ExtHost *extension.Host
LuaHost *luahost.Host
notify chan error // Combined notification for failed services.
ready *sync.WaitGroup // Tracks services that have not reported ready.
}
// FullAssembly wires up a complete Inbucket environment.
func FullAssembly(conf *config.Root) (*Services, error) {
// Configure extensions.
extHost := extension.NewHost()
luaHost, err := luahost.New(conf.Lua, extHost)
if err != nil {
return nil, err
}
// Configure storage.
store, err := storage.FromConfig(conf.Storage, extHost)
if err != nil {
return nil, err
}
addrPolicy := &policy.Addressing{Config: conf}
// Configure shared components.
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)
// Configure routes and build HTTP server.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
webServer := web.NewServer(conf, mmanager, msgHub)
pop3Server := pop3.NewServer(conf.POP3, store)
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
return &Services{
MsgHub: msgHub,
RetentionScanner: retentionScanner,
POP3Server: pop3Server,
SMTPServer: smtpServer,
WebServer: webServer,
ExtHost: extHost,
LuaHost: luaHost,
ready: &sync.WaitGroup{},
}, nil
}
// Start all services, returns immediately. Callers may use Notify to detect failed services.
func (s *Services) Start(ctx context.Context, readyFunc func()) {
go s.MsgHub.Start(ctx)
go s.WebServer.Start(ctx, s.makeReadyFunc())
go s.SMTPServer.Start(ctx, s.makeReadyFunc())
go s.POP3Server.Start(ctx, s.makeReadyFunc())
go s.RetentionScanner.Start(ctx)
// Notify when all services report ready.
go func() {
s.ready.Wait()
readyFunc()
}()
}
// Notify merges the error notification channels of all fallible services, allowing the process to
// be shutdown if needed.
func (s *Services) Notify() <-chan error {
c := make(chan error, 1)
go func() {
// TODO: What level to log failure.
select {
case err := <-s.POP3Server.Notify():
c <- err
case err := <-s.SMTPServer.Notify():
c <- err
case err := <-s.WebServer.Notify():
c <- err
}
}()
return c
}
func (s *Services) makeReadyFunc() func() {
s.ready.Add(1)
var once sync.Once
return func() {
once.Do(s.ready.Done)
}
}
+28 -25
View File
@@ -13,47 +13,53 @@ import (
// Server defines an instance of the POP3 server. // Server defines an instance of the POP3 server.
type Server struct { type Server struct {
config config.POP3 // POP3 configuration. config config.POP3 // POP3 configuration.
store storage.Store // Mail store. store storage.Store // Mail store.
listener net.Listener // TCP listener. listener net.Listener // TCP listener.
globalShutdown chan bool // Inbucket shutdown signal. wg *sync.WaitGroup // Waitgroup tracking sessions.
wg *sync.WaitGroup // Waitgroup tracking sessions. notify chan error // Notify on fatal error.
} }
// New creates a new Server struct. // NewServer creates a new, unstarted, POP3 server.
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server { func NewServer(pop3Config config.POP3, store storage.Store) *Server {
return &Server{ return &Server{
config: pop3Config, config: pop3Config,
store: store, store: store,
globalShutdown: shutdownChan, wg: new(sync.WaitGroup),
wg: new(sync.WaitGroup), notify: make(chan error, 1),
} }
} }
// Start the server and listen for connections // Start the server and listen for connections
func (s *Server) Start(ctx context.Context) { func (s *Server) Start(ctx context.Context, readyFunc func()) {
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger() slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr) addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil { if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address") slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4") slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr) s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil { if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener") slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
// Listener go routine.
// Start listener go routine.
go s.serve(ctx) go s.serve(ctx)
readyFunc()
// Wait for shutdown. // Wait for shutdown.
select { select {
case _ = <-ctx.Done(): case _ = <-ctx.Done():
} }
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger() slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
slog.Debug().Msg("POP3 shutdown requested, connections will be drained") slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit. // Closing the listener will cause the serve() go routine to exit.
if err := s.listener.Close(); err != nil { if err := s.listener.Close(); err != nil {
slog.Error().Err(err).Msg("Failed to close POP3 listener") slog.Error().Err(err).Msg("Failed to close POP3 listener")
@@ -88,7 +94,8 @@ func (s *Server) serve(ctx context.Context) {
return return
default: default:
// Something went wrong. // Something went wrong.
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
} }
@@ -100,18 +107,14 @@ func (s *Server) serve(ctx context.Context) {
} }
} }
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active POP3 sessions have finished // Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() { func (s *Server) Drain() {
// Wait for sessions to close // Wait for sessions to close
s.wg.Wait() s.wg.Wait()
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained") log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
} }
// Notify allows the running POP3 server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
}
+29 -14
View File
@@ -13,9 +13,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
) )
// State tracks the current mode of our SMTP state machine. // State tracks the current mode of our SMTP state machine.
@@ -54,11 +54,11 @@ const (
QUIT QUIT
) )
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while // fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive, // as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
// (?:) is non-grouping sub-match // non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
var fromRegex = regexp.MustCompile( var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$") `(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( ([\w= ]|=<>)+)?$`)
func (s State) String() string { func (s State) String() string {
switch s { switch s {
@@ -144,8 +144,8 @@ func (s *Session) String() string {
* 4. If bad cmd, respond error * 4. If bad cmd, respond error
* 5. Goto 2 * 5. Goto 2
*/ */
func (s *Server) startSession(id int, conn net.Conn) { func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
logger := log.Hook(logHook{}).With(). logger = logger.Hook(logHook{}).With().
Str("module", "smtp"). Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()). Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger() Int("session", id).Logger()
@@ -384,7 +384,8 @@ func (s *Session) readyHandler(cmd string, arg string) {
return return
} }
from := m[1] from := m[1]
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil { localpart, domain, err := policy.ParseEmailAddress(from)
if from != "" && err != nil {
s.send("501 Bad sender address syntax") s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err) s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return return
@@ -416,10 +417,22 @@ func (s *Session) readyHandler(cmd string, arg string) {
} }
} }
} }
s.from = from
s.logger.Info().Msgf("Mail from: %v", from) // Process through extensions.
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from)) extResult := s.extHost.Events.BeforeMailAccepted.Emit(
s.enterState(MAIL) &event.AddressParts{Local: localpart, Domain: domain})
if extResult == nil || *extResult {
// Permitted by extension, or none had an opinion.
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else {
s.send("550 Mail denied by policy")
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
return
}
} else if cmd == "EHLO" { } else if cmd == "EHLO" {
// Reset session // Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request") s.logger.Debug().Msgf("Resetting session state on EHLO request")
@@ -615,11 +628,13 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// parseArgs takes the arguments proceeding a command and files them // parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg // into a map[string]string after uppercasing each key. Sample arg
// string: // string:
// " BODY=8BITMIME SIZE=1024" //
// " BODY=8BITMIME SIZE=1024"
//
// The leading space is mandatory. // The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) { func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string) args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`) re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
pm := re.FindAllStringSubmatch(arg, -1) pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil { if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg) s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
+234 -152
View File
@@ -1,22 +1,23 @@
package smtp package smtp
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"log"
"net" "net"
"net/textproto" "net/textproto"
"os"
"testing" "testing"
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test" "github.com/inbucket/inbucket/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
) )
type scriptStep struct { type scriptStep struct {
@@ -24,14 +25,44 @@ type scriptStep struct {
expect int expect int
} }
// Test commands in GREET state // Test valid commands in GREET state.
func TestGreetStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"HELO mydomain", 250},
{"HELO mydom.com", 250},
{"HelO mydom.com", 250},
{"helo 127.0.0.1", 250},
{"HELO ABC", 250},
{"EHLO mydomain", 250},
{"EHLO mydom.com", 250},
{"EhlO mydom.com", 250},
{"ehlo 127.0.0.1", 250},
{"EHLO a", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
defer server.Drain() // Required to prevent test logging data race.
script := []scriptStep{
tc,
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
})
}
}
// Test invalid commands in GREET state.
func TestGreetState(t *testing.T) { func TestGreetState(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds) server := setupSMTPServer(ds, extension.NewHost())
defer teardown() defer server.Drain() // Required to prevent test logging data race.
// Test out some mangled HELOs tests := []scriptStep{
script := []scriptStep{
{"HELO", 501}, {"HELO", 501},
{"EHLO", 501}, {"EHLO", 501},
{"HELLO", 500}, {"HELLO", 500},
@@ -39,57 +70,24 @@ func TestGreetState(t *testing.T) {
{"hello", 500}, {"hello", 500},
{"Outlook", 500}, {"Outlook", 500},
} }
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Valid HELOs for _, tc := range tests {
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil { t.Run(tc.send, func(t *testing.T) {
t.Error(err) defer server.Drain() // Required to prevent test logging data race.
} script := []scriptStep{
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil { tc,
t.Error(err) {"QUIT", 221}}
} if err := playSession(t, server, script); err != nil {
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil { t.Error(err)
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)
} }
} }
// Test commands in READY state
func TestEmptyEnvelope(t *testing.T) { func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds) server := setupSMTPServer(ds, extension.NewHost())
defer teardown() defer server.Drain()
// Test out some empty envelope without blanks // Test out some empty envelope without blanks
script := []scriptStep{ script := []scriptStep{
@@ -97,8 +95,6 @@ func TestEmptyEnvelope(t *testing.T) {
{"MAIL FROM:<>", 250}, {"MAIL FROM:<>", 250},
} }
if err := playSession(t, server, script); err != nil { 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) t.Error(err)
} }
@@ -108,17 +104,15 @@ func TestEmptyEnvelope(t *testing.T) {
{"MAIL FROM: <>", 250}, {"MAIL FROM: <>", 250},
} }
if err := playSession(t, server, script); err != nil { 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) t.Error(err)
} }
} }
// Test AUTH // Test AUTH commands.
func TestAuth(t *testing.T) { func TestAuth(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds) server := setupSMTPServer(ds, extension.NewHost())
defer teardown() defer server.Drain()
// PLAIN AUTH // PLAIN AUTH
script := []scriptStep{ script := []scriptStep{
@@ -149,24 +143,67 @@ func TestAuth(t *testing.T) {
if err := playSession(t, server, script); err != nil { if err := playSession(t, server, script); err != nil {
t.Error(err) t.Error(err)
} }
}
if t.Failed() { // Test TLS commands.
// Wait for handler to finish logging func TestTLS(t *testing.T) {
time.Sleep(2 * time.Second) ds := test.NewStore()
// Dump buffered log data if there was a failure server := setupSMTPServer(ds, extension.NewHost())
_, _ = io.Copy(os.Stderr, logbuf) defer server.Drain()
// Test Start TLS parsing.
script := []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
} }
} }
// Test commands in READY state // Test valid commands in READY state.
func TestReadyState(t *testing.T) { func TestReadyStateValidCommands(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds) server := setupSMTPServer(ds, extension.NewHost())
defer teardown()
// Test out some mangled READY commands // Test out some valid MAIL commands
script := []scriptStep{ tests := []scriptStep{
{"HELO localhost", 250}, {"MAIL FROM:<john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
defer server.Drain()
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
})
}
}
// Test invalid commands in READY state.
func TestReadyStateInvalidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"FOOB", 500}, {"FOOB", 500},
{"HELO", 503}, {"HELO", 503},
{"DATA", 503}, {"DATA", 503},
@@ -178,59 +215,27 @@ func TestReadyState(t *testing.T) {
{"MAIL FROM:<first@last@gmail.com>", 501}, {"MAIL FROM:<first@last@gmail.com>", 501},
{"MAIL FROM:<first last@gmail.com>", 501}, {"MAIL FROM:<first last@gmail.com>", 501},
} }
if err := playSession(t, server, script); err != nil {
t.Error(err) for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
defer server.Drain()
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
})
} }
// Test out some valid MAIL commands
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// 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)
}
} }
// Test commands in MAIL state // Test commands in MAIL state
func TestMailState(t *testing.T) { func TestMailState(t *testing.T) {
mds := test.NewStore() mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds) server := setupSMTPServer(mds, extension.NewHost())
defer teardown() defer server.Drain()
// Test out some mangled READY commands // Test out some mangled READY commands
script := []scriptStep{ script := []scriptStep{
@@ -262,6 +267,8 @@ func TestMailState(t *testing.T) {
{"RSET", 250}, {"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250}, {"MAIL FROM:<john@gmail.com>", 250},
{`RCPT TO:<"first/last"@host.com`, 250}, {`RCPT TO:<"first/last"@host.com`, 250},
{"RCPT TO:<u1@[127.0.0.1]>", 250},
{"RCPT TO:<u1@[IPv6:2001:db8:aaaa:1::100]>", 250},
} }
if err := playSession(t, server, script); err != nil { if err := playSession(t, server, script); err != nil {
t.Error(err) t.Error(err)
@@ -329,23 +336,16 @@ func TestMailState(t *testing.T) {
if err := playSession(t, server, script); err != nil { if err := playSession(t, server, script); err != nil {
t.Error(err) t.Error(err)
} }
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
} }
// Test commands in DATA state // Test commands in DATA state
func TestDataState(t *testing.T) { func TestDataState(t *testing.T) {
mds := test.NewStore() mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds) server := setupSMTPServer(mds, extension.NewHost())
defer teardown() defer server.Drain()
var script []scriptStep var script []scriptStep
pipe := setupSMTPSession(server) pipe := setupSMTPSession(t, server)
c := textproto.NewConn(pipe) c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil { if code, _, err := c.ReadCodeLine(220); err != nil {
@@ -360,6 +360,7 @@ func TestDataState(t *testing.T) {
if err := playScriptAgainst(t, c, script); err != nil { if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err) t.Error(err)
} }
// Send a message // Send a message
body := `To: u1@gmail.com body := `To: u1@gmail.com
From: john@gmail.com From: john@gmail.com
@@ -373,9 +374,11 @@ Hi!
if code, _, err := c.ReadCodeLine(250); err != nil { if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code) t.Errorf("Expected a 250 greeting, got %v", code)
} }
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
// Test with no useful headers. // Test with no useful headers.
pipe = setupSMTPSession(server) pipe = setupSMTPSession(t, server)
c = textproto.NewConn(pipe) c = textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil { if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code) t.Errorf("Expected a 220 greeting, got %v", code)
@@ -389,29 +392,25 @@ Hi!
if err := playScriptAgainst(t, c, script); err != nil { if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err) t.Error(err)
} }
// Send a message // Send a message
body = `X-Useless-Header: true body = `X-Useless-Header: true
Hi! Can you still deliver this? Hi! Can you still deliver this?
` `
dw = c.DotWriter() dw = c.DotWriter()
_, _ = io.WriteString(dw, body) _, _ = io.WriteString(dw, body)
_ = dw.Close() _ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil { if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code) t.Errorf("Expected a 250 greeting, got %v", code)
} }
_, _ = c.Cmd("QUIT")
if t.Failed() { _, _, _ = c.ReadCodeLine(221)
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
} }
// playSession creates a new session, reads the greeting and then plays the script // playSession creates a new session, reads the greeting and then plays the script
func playSession(t *testing.T, server *Server, script []scriptStep) error { func playSession(t *testing.T, server *Server, script []scriptStep) error {
pipe := setupSMTPSession(server) pipe := setupSMTPSession(t, server)
c := textproto.NewConn(pipe) c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil { if code, _, err := c.ReadCodeLine(220); err != nil {
@@ -452,6 +451,93 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
return nil return nil
} }
// Tests "MAIL FROM" emits BeforeMailAccepted event.
func TestBeforeMailAcceptedEventEmitted(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
defer server.Drain()
var got *event.AddressParts
extHost.Events.BeforeMailAccepted.AddListener(
"test",
func(addr event.AddressParts) *bool {
got = &addr
return nil
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
assert.NotNil(t, got, "BeforeMailListener did not receive Address")
assert.Equal(t, "john", got.Local, "Address local part had wrong value")
assert.Equal(t, "gmail.com", got.Domain, "Address domain part had wrong value")
}
// Test "MAIL FROM" acts on BeforeMailAccepted event result.
func TestBeforeMailAcceptedEventResponse(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
defer server.Drain()
var shouldReturn *bool
var gotEvent *event.AddressParts
extHost.Events.BeforeMailAccepted.AddListener(
"test",
func(addr event.AddressParts) *bool {
gotEvent = &addr
return shouldReturn
})
allowRes := true
denyRes := false
tcs := map[string]struct {
script scriptStep // Command to send and SMTP code expected.
eventRes *bool // Response to send from event listener.
}{
"allow": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: &allowRes,
},
"deny": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 550},
eventRes: &denyRes,
},
"defer": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: nil,
},
}
for name, tc := range tcs {
tc := tc
t.Run(name, func(t *testing.T) {
// Reset event listener.
shouldReturn = tc.eventRes
gotEvent = nil
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
tc.script,
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
assert.NotNil(t, gotEvent, "BeforeMailListener did not receive Address")
})
}
}
// net.Pipe does not implement deadlines // net.Pipe does not implement deadlines
type mockConn struct { type mockConn struct {
net.Conn net.Conn
@@ -461,7 +547,7 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil } func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil } func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) { func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
cfg := &config.Root{ cfg := &config.Root{
MailboxNaming: config.FullNaming, MailboxNaming: config.FullNaming,
SMTP: config.SMTP{ SMTP: config.SMTP{
@@ -474,28 +560,24 @@ func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown f
Timeout: 5, Timeout: 5,
}, },
} }
// Capture log output.
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it. // Create a server, don't start it.
shutdownChan := make(chan bool)
teardown = func() {
close(shutdownChan)
}
addrPolicy := &policy.Addressing{Config: cfg} addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds} manager := &message.StoreManager{Store: ds}
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
return s, buf, teardown return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
} }
var sessionNum int var sessionNum int
func setupSMTPSession(server *Server) net.Conn { func setupSMTPSession(t *testing.T, server *Server) net.Conn {
// Pair of pipes to communicate. logger := zerolog.New(zerolog.NewTestWriter(t))
serverConn, clientConn := net.Pipe() serverConn, clientConn := net.Pipe()
// Start the session. // Start the session.
server.wg.Add(1) server.wg.Add(1)
sessionNum++ sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn}) go server.startSession(sessionNum, &mockConn{serverConn}, logger)
return clientConn return clientConn
} }
+36 -31
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/metric" "github.com/inbucket/inbucket/pkg/metric"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
@@ -58,21 +59,22 @@ func init() {
// Server holds the configuration and state of our SMTP server. // Server holds the configuration and state of our SMTP server.
type Server struct { type Server struct {
config config.SMTP // SMTP configuration. config config.SMTP // SMTP configuration.
addrPolicy *policy.Addressing // Address policy. tlsConfig *tls.Config // TLS encryption configuration.
globalShutdown chan bool // Shuts down Inbucket. addrPolicy *policy.Addressing // Address policy.
manager message.Manager // Used to deliver messages. manager message.Manager // Used to deliver messages.
listener net.Listener // Incoming network connections. extHost *extension.Host // Extension event processor.
wg *sync.WaitGroup // Waitgroup tracks individual sessions. listener net.Listener // Incoming network connections.
tlsConfig *tls.Config 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( func NewServer(
smtpConfig config.SMTP, smtpConfig config.SMTP,
globalShutdown chan bool,
manager message.Manager, manager message.Manager,
apolicy *policy.Addressing, apolicy *policy.Addressing,
extHost *extension.Host,
) *Server { ) *Server {
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger() slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{} tlsConfig := &tls.Config{}
@@ -90,37 +92,44 @@ func NewServer(
} }
return &Server{ return &Server{
config: smtpConfig, config: smtpConfig,
globalShutdown: globalShutdown, tlsConfig: tlsConfig,
manager: manager, manager: manager,
addrPolicy: apolicy, addrPolicy: apolicy,
wg: new(sync.WaitGroup), extHost: extHost,
tlsConfig: tlsConfig, wg: new(sync.WaitGroup),
notify: make(chan error, 1),
} }
} }
// Start the listener and handle incoming connections. // Start the listener and handle incoming connections.
func (s *Server) Start(ctx context.Context) { func (s *Server) Start(ctx context.Context, readyFunc func()) {
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger() slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr) addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil { if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address") slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4") slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr) s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil { if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener") slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
// Listener go routine.
// Start listener go routine.
go s.serve(ctx) go s.serve(ctx)
readyFunc()
// Wait for shutdown. // Wait for shutdown.
<-ctx.Done() <-ctx.Done()
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger() slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
slog.Debug().Msg("SMTP shutdown requested, connections will be drained") slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit. // Closing the listener will cause the serve() go routine to exit.
if err := s.listener.Close(); err != nil { if err := s.listener.Close(); err != nil {
slog.Error().Err(err).Msg("Failed to close SMTP listener") slog.Error().Err(err).Msg("Failed to close SMTP listener")
@@ -156,7 +165,8 @@ func (s *Server) serve(ctx context.Context) {
return return
default: default:
// Something went wrong. // Something went wrong.
s.emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
} }
@@ -164,23 +174,18 @@ func (s *Server) serve(ctx context.Context) {
tempDelay = 0 tempDelay = 0
expConnectsTotal.Add(1) expConnectsTotal.Add(1)
s.wg.Add(1) s.wg.Add(1)
go s.startSession(sessionID, conn) go s.startSession(sessionID, conn, log.Logger)
} }
} }
} }
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket.
select {
case <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active SMTP sessions have finished // Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() { func (s *Server) Drain() {
// Wait for sessions to close. // Wait for sessions to close.
s.wg.Wait() s.wg.Wait()
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained") }
// Notify allows the running SMTP server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
} }
+30 -22
View File
@@ -31,10 +31,9 @@ var (
// incoming requests to the correct handler function // incoming requests to the correct handler function
Router = mux.NewRouter() Router = mux.NewRouter()
rootConfig *config.Root rootConfig *config.Root
server *http.Server server *http.Server
listener net.Listener listener net.Listener
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets // ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int) ExpWebSocketConnectsCurrent = new(expvar.Int)
@@ -45,15 +44,19 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent) m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
} }
// Initialize sets up things for unit tests or the Start() method. // Server defines an instance of the Web server.
func Initialize( type Server struct {
// TODO Migrate global vars here.
notify chan error // Notify on fatal error.
}
// NewServer sets up things for unit tests or the Start() method.
func NewServer(
conf *config.Root, conf *config.Root,
shutdownChan chan bool,
mm message.Manager, mm message.Manager,
mh *msghub.Hub) { mh *msghub.Hub) *Server {
rootConfig = conf rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers. // NewContext() will use this DataStore for the web handlers.
msgHub = mh msgHub = mh
@@ -118,10 +121,16 @@ func Initialize(
http.StatusNotFound, "No route matches URI path") http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler( Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path") http.StatusMethodNotAllowed, "Method not allowed for URI path")
s := &Server{
notify: make(chan error, 1),
}
return s
} }
// Start begins listening for HTTP requests // Start begins listening for HTTP requests
func Start(ctx context.Context) { func (s *Server) Start(ctx context.Context, readyFunc func()) {
server = &http.Server{ server = &http.Server{
Addr: rootConfig.Web.Addr, Addr: rootConfig.Web.Addr,
Handler: requestLoggingWrapper(Router), Handler: requestLoggingWrapper(Router),
@@ -137,12 +146,14 @@ func Start(ctx context.Context) {
if err != nil { if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err). log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP failed to start TCP4 listener") Msg("HTTP failed to start TCP4 listener")
emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
// Listener go routine // Start listener go routine
go serve(ctx) go s.serve(ctx)
readyFunc()
// Wait for shutdown // Wait for shutdown
select { select {
@@ -176,7 +187,7 @@ func appConfigCookie(webConfig config.Web) *http.Cookie {
} }
// serve begins serving HTTP requests // serve begins serving HTTP requests
func serve(ctx context.Context) { func (s *Server) serve(ctx context.Context) {
// server.Serve blocks until we close the listener // server.Serve blocks until we close the listener
err := server.Serve(listener) err := server.Serve(listener)
@@ -186,16 +197,13 @@ func serve(ctx context.Context) {
default: default:
log.Error().Str("module", "web").Str("phase", "startup").Err(err). log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP server failed") Msg("HTTP server failed")
emergencyShutdown() s.notify <- err
close(s.notify)
return return
} }
} }
func emergencyShutdown() { // Notify allows the running Web server to be monitored for a fatal error.
// Shutdown Inbucket func (s *Server) Notify() <-chan error {
select { return s.notify
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
} }
+16 -1
View File
@@ -10,6 +10,8 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil" "github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -45,10 +47,11 @@ type Store struct {
mailPath string mailPath string
messageCap int messageCap int
bufReaderPool sync.Pool bufReaderPool sync.Pool
extHost *extension.Host
} }
// New creates a new DataStore object using the specified path // New creates a new DataStore object using the specified path
func New(cfg config.Storage) (storage.Store, error) { func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
path := cfg.Params["path"] path := cfg.Params["path"]
if path == "" { if path == "" {
return nil, fmt.Errorf("'path' parameter not specified") return nil, fmt.Errorf("'path' parameter not specified")
@@ -70,6 +73,7 @@ func New(cfg config.Storage) (storage.Store, error) {
return bufio.NewReader(nil) return bufio.NewReader(nil)
}, },
}, },
extHost: extHost,
}, nil }, nil
} }
@@ -183,6 +187,17 @@ func (fs *Store) PurgeMessages(mailbox string) error {
mb := fs.mbox(mailbox) mb := fs.mbox(mailbox)
mb.Lock() mb.Lock()
defer mb.Unlock() defer mb.Unlock()
// Emit delete events.
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
fs.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return mb.purge() return mb.purge()
} }
+19 -13
View File
@@ -14,6 +14,8 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test" "github.com/inbucket/inbucket/pkg/test"
@@ -22,18 +24,19 @@ import (
// TestSuite runs storage package test suite on file store. // TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) { func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) { test.StoreSuite(t,
ds, _ := setupDataStore(conf) func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
destroy := func() { ds, _ := setupDataStore(conf, extHost)
teardownDataStore(ds) destroy := func() {
} teardownDataStore(ds)
return ds, destroy, nil }
}) return ds, destroy, nil
})
} }
// Test directory structure created by filestore // Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) { func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{}) ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds) defer teardownDataStore(ds)
root := ds.path root := ds.path
@@ -111,7 +114,7 @@ func TestFSDirStructure(t *testing.T) {
// Test missing files // Test missing files
func TestFSMissing(t *testing.T) { func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{}) ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds) defer teardownDataStore(ds)
mbName := "fred" mbName := "fred"
@@ -146,7 +149,7 @@ func TestFSMissing(t *testing.T) {
// Test Get the latest message // Test Get the latest message
func TestGetLatestMessage(t *testing.T) { func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{}) ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds) defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943 // james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
@@ -188,22 +191,25 @@ func TestGetLatestMessage(t *testing.T) {
} }
// setupDataStore creates a new FileDataStore in a temporary directory // setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) { func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket") path, err := ioutil.TempDir("", "inbucket")
if err != nil { if err != nil {
panic(err) panic(err)
} }
// Capture log output. // Capture log output.
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
log.SetOutput(buf) log.SetOutput(buf)
if cfg.Params == nil { if cfg.Params == nil {
cfg.Params = make(map[string]string) cfg.Params = make(map[string]string)
} }
cfg.Params["path"] = path cfg.Params["path"] = path
s, err := New(cfg) s, err := New(cfg, extHost)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return s.(*Store), buf return s.(*Store), buf
} }
@@ -211,7 +217,7 @@ func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
// the size of the generated message. // the size of the generated message.
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) { func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
// Build message for delivery // Build message for delivery
meta := message.Metadata{ meta := event.MessageMetadata{
Mailbox: mbName, Mailbox: mbName,
To: []*mail.Address{{Name: "", Address: "somebody@host"}}, To: []*mail.Address{{Name: "", Address: "somebody@host"}},
From: &mail.Address{Name: "", Address: "somebodyelse@host"}, From: &mail.Address{Name: "", Address: "somebodyelse@host"},
+5
View File
@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -72,6 +73,10 @@ func (mb *mbox) removeMessage(id string) error {
msg = m msg = m
// Slice around message we are deleting // Slice around message we are deleting
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...) mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
// Emit deleted event.
mb.store.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(msg))
break break
} }
} }
+22 -4
View File
@@ -8,6 +8,8 @@ import (
"sync" "sync"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
) )
@@ -18,6 +20,7 @@ type Store struct {
cap int // Per-mailbox message cap. cap int // Per-mailbox message cap.
incoming chan *msgDone // New messages for size enforcer. incoming chan *msgDone // New messages for size enforcer.
remove chan *msgDone // Remove deleted messages from size enforcer. remove chan *msgDone // Remove deleted messages from size enforcer.
extHost *extension.Host
} }
type mbox struct { type mbox struct {
@@ -31,10 +34,11 @@ type mbox struct {
var _ storage.Store = &Store{} var _ storage.Store = &Store{}
// New returns an emtpy memory store. // New returns an emtpy memory store.
func New(cfg config.Storage) (storage.Store, error) { func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
s := &Store{ s := &Store{
boxes: make(map[string]*mbox), boxes: make(map[string]*mbox),
cap: cfg.MailboxMsgCap, cap: cfg.MailboxMsgCap,
extHost: extHost,
} }
if str, ok := cfg.Params["maxkb"]; ok { if str, ok := cfg.Params["maxkb"]; ok {
maxKB, err := strconv.ParseInt(str, 10, 64) maxKB, err := strconv.ParseInt(str, 10, 64)
@@ -140,16 +144,25 @@ func (s *Store) MarkSeen(mailbox, id string) error {
// PurgeMessages deletes the contents of a mailbox. // PurgeMessages deletes the contents of a mailbox.
func (s *Store) PurgeMessages(mailbox string) error { func (s *Store) PurgeMessages(mailbox string) error {
// Grab lock, copy messages, clear, and drop lock.
var messages map[string]*Message var messages map[string]*Message
s.withMailbox(mailbox, true, func(mb *mbox) { s.withMailbox(mailbox, true, func(mb *mbox) {
messages = mb.messages messages = mb.messages
mb.messages = make(map[string]*Message) mb.messages = make(map[string]*Message)
}) })
if len(messages) > 0 && s.remove != nil {
// Process size/quota.
if s.remove != nil {
for _, m := range messages { for _, m := range messages {
s.enforcerRemove(m) s.enforcerRemove(m)
} }
} }
// Emit delete events.
for _, m := range messages {
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return nil return nil
} }
@@ -163,6 +176,11 @@ func (s *Store) removeMessage(mailbox, id string) *Message {
delete(mb.messages, id) delete(mb.messages, id)
} }
}) })
if m != nil {
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return m return m
} }
+16 -7
View File
@@ -6,27 +6,32 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test" "github.com/inbucket/inbucket/pkg/test"
) )
// TestSuite runs storage package test suite on file store. // TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) { func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) { test.StoreSuite(t,
s, _ := New(conf) func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
destroy := func() {} s, _ := New(conf, extHost)
return s, destroy, nil destroy := func() {}
}) return s, destroy, nil
})
} }
// TestMessageList verifies the operation of the global message list: mem.Store.messages. // TestMessageList verifies the operation of the global message list: mem.Store.messages.
func TestMaxSize(t *testing.T) { func TestMaxSize(t *testing.T) {
extHost := extension.NewHost()
maxSize := int64(2048) maxSize := int64(2048)
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}}) s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}}, extHost)
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"} boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
// Ensure capacity so we do not block population.
n := 10 n := 10
// total := 50
sizeChan := make(chan int64, len(boxes)) sizeChan := make(chan int64, len(boxes))
// Populate mailboxes concurrently. // Populate mailboxes concurrently.
for _, mailbox := range boxes { for _, mailbox := range boxes {
go func(mailbox string) { go func(mailbox string) {
@@ -38,11 +43,13 @@ func TestMaxSize(t *testing.T) {
sizeChan <- size sizeChan <- size
}(mailbox) }(mailbox)
} }
// Wait for sizes. // Wait for sizes.
sentBytesTotal := int64(0) sentBytesTotal := int64(0)
for range boxes { for range boxes {
sentBytesTotal += <-sizeChan sentBytesTotal += <-sizeChan
} }
// Calculate actual size. // Calculate actual size.
gotSize := int64(0) gotSize := int64(0)
s.VisitMailboxes(func(messages []storage.Message) bool { s.VisitMailboxes(func(messages []storage.Message) bool {
@@ -51,6 +58,7 @@ func TestMaxSize(t *testing.T) {
} }
return true return true
}) })
// Verify state. Messages are ~75 bytes each. // Verify state. Messages are ~75 bytes each.
if gotSize < 2048-75 { if gotSize < 2048-75 {
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75) t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
@@ -58,6 +66,7 @@ func TestMaxSize(t *testing.T) {
if gotSize > maxSize { if gotSize > maxSize {
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize) t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
} }
// Purge all messages concurrently, testing for deadlocks. // Purge all messages concurrently, testing for deadlocks.
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(len(boxes)) wg.Add(len(boxes))
+15 -18
View File
@@ -2,6 +2,7 @@ package storage
import ( import (
"container/list" "container/list"
"context"
"expvar" "expvar"
"time" "time"
@@ -50,7 +51,6 @@ func init() {
// RetentionScanner looks for messages older than the configured retention period and deletes them. // RetentionScanner looks for messages older than the configured retention period and deletes them.
type RetentionScanner struct { type RetentionScanner struct {
globalShutdown chan bool // Closes when Inbucket needs to shut down
retentionShutdown chan bool // Closed after the scanner has shut down retentionShutdown chan bool // Closed after the scanner has shut down
ds Store ds Store
retentionPeriod time.Duration retentionPeriod time.Duration
@@ -61,10 +61,8 @@ type RetentionScanner struct {
func NewRetentionScanner( func NewRetentionScanner(
cfg config.Storage, cfg config.Storage,
ds Store, ds Store,
shutdownChannel chan bool,
) *RetentionScanner { ) *RetentionScanner {
rs := &RetentionScanner{ rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool), retentionShutdown: make(chan bool),
ds: ds, ds: ds,
retentionPeriod: cfg.RetentionPeriod, retentionPeriod: cfg.RetentionPeriod,
@@ -76,20 +74,16 @@ func NewRetentionScanner(
} }
// Start up the retention scanner if retention period > 0 // Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start() { func (rs *RetentionScanner) Start(ctx context.Context) {
slog := log.With().Str("module", "storage").Logger()
if rs.retentionPeriod <= 0 { if rs.retentionPeriod <= 0 {
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled") slog.Info().Str("phase", "startup").Msg("Retention scanner disabled")
close(rs.retentionShutdown) close(rs.retentionShutdown)
return return
} }
log.Info().Str("phase", "startup").Str("module", "storage"). slog.Info().Str("phase", "startup").Msgf("Retention configured for %v", rs.retentionPeriod)
Msgf("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
slog := log.With().Str("module", "storage").Logger()
start := time.Now() start := time.Now()
retentionLoop: retentionLoop:
for { for {
@@ -99,19 +93,19 @@ retentionLoop:
dur := time.Minute - since dur := time.Minute - since
slog.Debug().Msgf("Retention scanner sleeping for %v", dur) slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
select { select {
case <-rs.globalShutdown: case <-ctx.Done():
break retentionLoop break retentionLoop
case <-time.After(dur): case <-time.After(dur):
} }
} }
// Kickoff scan // Kickoff scan
start = time.Now() start = time.Now()
if err := rs.DoScan(); err != nil { if err := rs.DoScan(ctx); err != nil {
slog.Error().Err(err).Msg("Error during retention scan") slog.Error().Err(err).Msg("Error during retention scan")
} }
// Check for global shutdown // Check for global shutdown
select { select {
case <-rs.globalShutdown: case <-ctx.Done():
break retentionLoop break retentionLoop
default: default:
} }
@@ -121,13 +115,14 @@ retentionLoop:
} }
// DoScan does a single pass of all mailboxes looking for messages that can be purged. // DoScan does a single pass of all mailboxes looking for messages that can be purged.
func (rs *RetentionScanner) DoScan() error { func (rs *RetentionScanner) DoScan(ctx context.Context) error {
slog := log.With().Str("module", "storage").Logger() slog := log.With().Str("module", "storage").Logger()
slog.Debug().Msg("Starting retention scan") slog.Debug().Msg("Starting retention scan")
cutoff := time.Now().Add(-1 * rs.retentionPeriod) cutoff := time.Now().Add(-1 * rs.retentionPeriod)
// Loop over all mailboxes.
retained := 0 retained := 0
storeSize := int64(0) storeSize := int64(0)
// Loop over all mailboxes.
err := rs.ds.VisitMailboxes(func(messages []Message) bool { err := rs.ds.VisitMailboxes(func(messages []Message) bool {
for _, msg := range messages { for _, msg := range messages {
if msg.Date().Before(cutoff) { if msg.Date().Before(cutoff) {
@@ -145,7 +140,7 @@ func (rs *RetentionScanner) DoScan() error {
} }
} }
select { select {
case <-rs.globalShutdown: case <-ctx.Done():
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown") slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
return false return false
case <-time.After(rs.retentionSleep): case <-time.After(rs.retentionSleep):
@@ -156,10 +151,12 @@ func (rs *RetentionScanner) DoScan() error {
if err != nil { if err != nil {
return err return err
} }
// Update metrics // Update metrics
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000) scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
expRetainedCurrent.Set(int64(retained)) expRetainedCurrent.Set(int64(retained))
expRetainedSize.Set(storeSize) expRetainedSize.Set(storeSize)
return nil return nil
} }
+11 -4
View File
@@ -1,11 +1,13 @@
package storage_test package storage_test
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test" "github.com/inbucket/inbucket/pkg/test"
@@ -13,6 +15,7 @@ import (
func TestDoRetentionScan(t *testing.T) { func TestDoRetentionScan(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
// Mockup some different aged messages (num is in hours) // Mockup some different aged messages (num is in hours)
new1 := stubMessage("mb1", 0) new1 := stubMessage("mb1", 0)
new2 := stubMessage("mb2", 1) new2 := stubMessage("mb2", 1)
@@ -26,22 +29,26 @@ func TestDoRetentionScan(t *testing.T) {
ds.AddMessage(old3) ds.AddMessage(old3)
ds.AddMessage(new2) ds.AddMessage(new2)
ds.AddMessage(new3) ds.AddMessage(new3)
// Test 4 hour retention // Test 4 hour retention
cfg := config.Storage{ cfg := config.Storage{
RetentionPeriod: 239 * time.Minute, RetentionPeriod: 239 * time.Minute,
RetentionSleep: 0, RetentionSleep: 0,
} }
shutdownChan := make(chan bool) ctx, cancel := context.WithCancel(context.Background())
rs := storage.NewRetentionScanner(cfg, ds, shutdownChan) defer cancel()
if err := rs.DoScan(); err != nil { rs := storage.NewRetentionScanner(cfg, ds)
if err := rs.DoScan(ctx); err != nil {
t.Error(err) t.Error(err)
} }
// Delete should not have been called on new messages // Delete should not have been called on new messages
for _, m := range []storage.Message{new1, new2, new3} { for _, m := range []storage.Message{new1, new2, new3} {
if ds.MessageDeleted(m) { if ds.MessageDeleted(m) {
t.Errorf("Expected %v to be present, was deleted", m.ID()) t.Errorf("Expected %v to be present, was deleted", m.ID())
} }
} }
// Delete should have been called once on old messages // Delete should have been called once on old messages
for _, m := range []storage.Message{old1, old2, old3} { for _, m := range []storage.Message{old1, old2, old3} {
if !ds.MessageDeleted(m) { if !ds.MessageDeleted(m) {
@@ -53,7 +60,7 @@ func TestDoRetentionScan(t *testing.T) {
// stubMessage creates a message stub of a specific age // stubMessage creates a message stub of a specific age
func stubMessage(mailbox string, ageHours int) storage.Message { func stubMessage(mailbox string, ageHours int) storage.Message {
return &message.Delivery{ return &message.Delivery{
Meta: message.Metadata{ Meta: event.MessageMetadata{
Mailbox: mailbox, Mailbox: mailbox,
ID: fmt.Sprintf("MSG[age=%vh]", ageHours), ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour), Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
+4 -3
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
) )
var ( var (
@@ -19,7 +20,7 @@ var (
ErrNotWritable = errors.New("Message not writable") ErrNotWritable = errors.New("Message not writable")
// Constructors tracks registered storage constructors // Constructors tracks registered storage constructors
Constructors = make(map[string]func(config.Storage) (Store, error)) Constructors = make(map[string]func(config.Storage, *extension.Host) (Store, error))
) )
// Store is the interface Inbucket uses to interact with storage implementations. // Store is the interface Inbucket uses to interact with storage implementations.
@@ -48,9 +49,9 @@ type Message interface {
} }
// FromConfig creates an instance of the Store based on the provided configuration. // FromConfig creates an instance of the Store based on the provided configuration.
func FromConfig(c config.Storage) (store Store, err error) { func FromConfig(c config.Storage, extHost *extension.Host) (store Store, err error) {
if cf := Constructors[c.Type]; cf != nil { if cf := Constructors[c.Type]; cf != nil {
return cf(c) return cf(c, extHost)
} }
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type) return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
} }
+84 -15
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub" "github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
@@ -32,6 +33,8 @@ const (
smtpHost = "127.0.0.1:2500" smtpHost = "127.0.0.1:2500"
) )
// TODO: Add suites for domain and full addressing modes.
func TestSuite(t *testing.T) { func TestSuite(t *testing.T) {
stopServer, err := startServer() stopServer, err := startServer()
if err != nil { if err != nil {
@@ -46,6 +49,8 @@ func TestSuite(t *testing.T) {
{"basic", testBasic}, {"basic", testBasic},
{"fullname", testFullname}, {"fullname", testFullname},
{"encodedHeader", testEncodedHeader}, {"encodedHeader", testEncodedHeader},
{"ipv4Recipient", testIPv4Recipient},
{"ipv6Recipient", testIPv6Recipient},
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, tc.test) t.Run(tc.name, tc.test)
@@ -139,6 +144,64 @@ func testEncodedHeader(t *testing.T) {
goldiff.File(t, got, "testdata", "encodedheader.golden") goldiff.File(t, got, "testdata", "encodedheader.golden")
} }
func testIPv4Recipient(t *testing.T) {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"ip4recipient@[192.168.123.123]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("ip4recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "no-to-ipv4.golden")
}
func testIPv6Recipient(t *testing.T) {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("ip6recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "no-to-ipv6.golden")
}
func formatMessage(m *client.Message) []byte { func formatMessage(m *client.Message) []byte {
b := &bytes.Buffer{} b := &bytes.Buffer{}
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox) fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
@@ -152,40 +215,46 @@ func formatMessage(m *client.Message) []byte {
} }
func startServer() (func(), error) { func startServer() (func(), error) {
// TODO Refactor inbucket/main.go so we don't need to repeat all this here. // TODO Move integration setup into lifecycle.
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true}) log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
extHost := extension.NewHost()
// Storage setup.
storage.Constructors["memory"] = mem.New storage.Constructors["memory"] = mem.New
os.Clearenv() os.Clearenv()
conf, err := config.Process() conf, err := config.Process()
if err != nil { if err != nil {
return nil, err return nil, err
} }
rootCtx, rootCancel := context.WithCancel(context.Background()) svcCtx, svcCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool) store, err := storage.FromConfig(conf.Storage, extHost)
store, err := storage.FromConfig(conf.Storage)
if err != nil { if err != nil {
rootCancel() svcCancel()
return nil, err return nil, err
} }
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
// TODO Test should not pass with unstarted msghub.
addrPolicy := &policy.Addressing{Config: conf} addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub} msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
// Start HTTP server. // Start HTTP server.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter()) webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub) webServer := web.NewServer(conf, mmanager, msgHub)
go web.Start(rootCtx) go webServer.Start(svcCtx, func() {})
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// TODO Implmement an elegant way to determine server readiness. // Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
go smtpServer.Start(svcCtx, func() {})
// TODO Use a readyFunc to determine server readiness.
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
return func() { return func() {
// Shut everything down. // Shut everything down.
close(shutdownChan) svcCancel()
rootCancel()
smtpServer.Drain() smtpServer.Drain()
}, nil }, nil
} }
+5 -4
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy" "github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
@@ -42,14 +43,14 @@ func (m *ManagerStub) GetMessage(mailbox, id string) (*message.Message, error) {
} }
// GetMetadata gets all the metadata for the specified mailbox. // GetMetadata gets all the metadata for the specified mailbox.
func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) { func (m *ManagerStub) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
if mailbox == "messageserr" { if mailbox == "messageserr" {
return nil, errors.New("internal error") return nil, errors.New("internal error")
} }
messages := m.mailboxes[mailbox] messages := m.mailboxes[mailbox]
metas := make([]*message.Metadata, len(messages)) metas := make([]*event.MessageMetadata, len(messages))
for i, msg := range messages { for i, msg := range messages {
metas[i] = &msg.Metadata metas[i] = &msg.MessageMetadata
} }
return metas, nil return metas, nil
} }
@@ -69,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
} }
for _, msg := range m.mailboxes[mailbox] { for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id { if msg.ID == id {
msg.Metadata.Seen = true msg.MessageMetadata.Seen = true
return nil return nil
} }
} }
+65 -30
View File
@@ -10,18 +10,23 @@ import (
"time" "time"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/extension"
"github.com/inbucket/inbucket/pkg/extension/event"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// StoreFactory returns a new store for the test suite. // StoreFactory returns a new store for the test suite.
type StoreFactory func(config.Storage) (store storage.Store, destroy func(), err error) type StoreFactory func(
config.Storage, *extension.Host) (store storage.Store, destroy func(), err error)
// StoreSuite runs a set of general tests on the provided Store. // StoreSuite runs a set of general tests on the provided Store.
func StoreSuite(t *testing.T, factory StoreFactory) { func StoreSuite(t *testing.T, factory StoreFactory) {
testCases := []struct { testCases := []struct {
name string name string
test func(*testing.T, storage.Store) test func(*testing.T, storage.Store, *extension.Host)
conf config.Storage conf config.Storage
}{ }{
{"metadata", testMetadata, config.Storage{}}, {"metadata", testMetadata, config.Storage{}},
@@ -39,18 +44,19 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
store, destroy, err := factory(tc.conf) extHost := extension.NewHost()
store, destroy, err := factory(tc.conf, extHost)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
tc.test(t, store) tc.test(t, store, extHost)
destroy() destroy()
}) })
} }
} }
// testMetadata verifies message metadata is stored and retrieved correctly. // testMetadata verifies message metadata is stored and retrieved correctly.
func testMetadata(t *testing.T, store storage.Store) { func testMetadata(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "testmailbox" mailbox := "testmailbox"
from := &mail.Address{Name: "From Person", Address: "from@person.com"} from := &mail.Address{Name: "From Person", Address: "from@person.com"}
to := []*mail.Address{ to := []*mail.Address{
@@ -61,7 +67,7 @@ func testMetadata(t *testing.T, store storage.Store) {
subject := "fantastic test subject line" subject := "fantastic test subject line"
content := "doesn't matter" content := "doesn't matter"
delivery := &message.Delivery{ delivery := &message.Delivery{
Meta: message.Metadata{ Meta: event.MessageMetadata{
// ID and Size will be determined by the Store. // ID and Size will be determined by the Store.
Mailbox: mailbox, Mailbox: mailbox,
From: from, From: from,
@@ -117,7 +123,7 @@ func testMetadata(t *testing.T, store storage.Store) {
} }
// testContent generates some binary content and makes sure it is correctly retrieved. // testContent generates some binary content and makes sure it is correctly retrieved.
func testContent(t *testing.T, store storage.Store) { func testContent(t *testing.T, store storage.Store, extHost *extension.Host) {
content := make([]byte, 5000) content := make([]byte, 5000)
for i := 0; i < len(content); i++ { for i := 0; i < len(content); i++ {
content[i] = byte(i % 256) content[i] = byte(i % 256)
@@ -130,7 +136,7 @@ func testContent(t *testing.T, store storage.Store) {
date := time.Now() date := time.Now()
subject := "fantastic test subject line" subject := "fantastic test subject line"
delivery := &message.Delivery{ delivery := &message.Delivery{
Meta: message.Metadata{ Meta: event.MessageMetadata{
// ID and Size will be determined by the Store. // ID and Size will be determined by the Store.
Mailbox: mailbox, Mailbox: mailbox,
From: from, From: from,
@@ -168,14 +174,13 @@ func testContent(t *testing.T, store storage.Store) {
} }
if errors > 5 { if errors > 5 {
t.Fatalf("Too many content errors, aborting test.") t.Fatalf("Too many content errors, aborting test.")
break
} }
} }
} }
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents // testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
// with a new GetMessages call each cycle. // with a new GetMessages call each cycle.
func testDeliveryOrder(t *testing.T, store storage.Store) { func testDeliveryOrder(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "fred" mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects { for i, subj := range subjects {
@@ -195,7 +200,7 @@ func testDeliveryOrder(t *testing.T, store storage.Store) {
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns // testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
// the last message sent. // the last message sent.
func testLatest(t *testing.T, store storage.Store) { func testLatest(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "fred" mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects { for _, subj := range subjects {
@@ -217,14 +222,14 @@ func testLatest(t *testing.T, store storage.Store) {
} }
// testNaming ensures the store does not enforce local part mailbox naming. // testNaming ensures the store does not enforce local part mailbox naming.
func testNaming(t *testing.T, store storage.Store) { func testNaming(t *testing.T, store storage.Store, extHost *extension.Host) {
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now()) DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(t, store, "fred", 0) GetAndCountMessages(t, store, "fred", 0)
GetAndCountMessages(t, store, "fred@fish.net", 1) GetAndCountMessages(t, store, "fred@fish.net", 1)
} }
// testSize verifies message content size metadata values. // testSize verifies message content size metadata values.
func testSize(t *testing.T, store storage.Store) { func testSize(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "fred" mailbox := "fred"
subjects := []string{"a", "br", "much longer than the others"} subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects)) sentIds := make([]string, len(subjects))
@@ -248,7 +253,7 @@ func testSize(t *testing.T, store storage.Store) {
} }
// testSeen verifies a message can be marked as seen. // testSeen verifies a message can be marked as seen.
func testSeen(t *testing.T, store storage.Store) { func testSeen(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "lisa" mailbox := "lisa"
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now()) id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now()) id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
@@ -284,22 +289,24 @@ func testSeen(t *testing.T, store storage.Store) {
} }
// testDelete creates and deletes some messages. // testDelete creates and deletes some messages.
func testDelete(t *testing.T, store storage.Store) { func testDelete(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "fred" mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects { for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now()) DeliverToStore(t, store, mailbox, subj, time.Now())
} }
msgs := GetAndCountMessages(t, store, mailbox, len(subjects)) msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
// Subscribe to events.
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
// Delete a couple messages. // Delete a couple messages.
err := store.RemoveMessage(mailbox, msgs[1].ID()) deleteIDs := []string{msgs[1].ID(), msgs[3].ID()}
if err != nil { for _, id := range deleteIDs {
t.Fatal(err) err := store.RemoveMessage(mailbox, id)
} require.NoError(t, err)
err = store.RemoveMessage(mailbox, msgs[3].ID())
if err != nil {
t.Fatal(err)
} }
// Confirm deletion. // Confirm deletion.
subjects = []string{"alpha", "charlie", "echo"} subjects = []string{"alpha", "charlie", "echo"}
msgs = GetAndCountMessages(t, store, mailbox, len(subjects)) msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
@@ -309,6 +316,17 @@ func testDelete(t *testing.T, store storage.Store) {
t.Errorf("Got subject %q, want %q", got, want) t.Errorf("Got subject %q, want %q", got, want)
} }
} }
// Capture events and check correct IDs were emitted.
ev1, err := eventListener()
require.NoError(t, err)
ev2, err := eventListener()
require.NoError(t, err)
eventIDs := []string{ev1.ID, ev2.ID}
for _, id := range deleteIDs {
assert.Contains(t, eventIDs, id)
}
// Try appending one more. // Try appending one more.
DeliverToStore(t, store, mailbox, "foxtrot", time.Now()) DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
subjects = []string{"alpha", "charlie", "echo", "foxtrot"} subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
@@ -322,23 +340,40 @@ func testDelete(t *testing.T, store storage.Store) {
} }
// testPurge makes sure mailboxes can be purged. // testPurge makes sure mailboxes can be purged.
func testPurge(t *testing.T, store storage.Store) { func testPurge(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "fred" mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
// Subscribe to events.
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
// Populate mailbox.
for _, subj := range subjects { for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now()) DeliverToStore(t, store, mailbox, subj, time.Now())
} }
GetAndCountMessages(t, store, mailbox, len(subjects)) GetAndCountMessages(t, store, mailbox, len(subjects))
// Purge and verify. // Purge and verify.
err := store.PurgeMessages(mailbox) err := store.PurgeMessages(mailbox)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
GetAndCountMessages(t, store, mailbox, 0) GetAndCountMessages(t, store, mailbox, 0)
// Confirm events emitted.
gotEvents := []*event.MessageMetadata{}
for range subjects {
ev, err := eventListener()
if err != nil {
t.Error(err)
break
}
gotEvents = append(gotEvents, ev)
}
assert.Equal(t, len(subjects), len(gotEvents),
"expected delete event for each message in mailbox")
} }
// testMsgCap verifies the message cap is enforced. // testMsgCap verifies the message cap is enforced.
func testMsgCap(t *testing.T, store storage.Store) { func testMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
mbCap := 10 mbCap := 10
mailbox := "captain" mailbox := "captain"
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
@@ -365,7 +400,7 @@ func testMsgCap(t *testing.T, store storage.Store) {
} }
// testNoMsgCap verfies a cap of 0 is not enforced. // testNoMsgCap verfies a cap of 0 is not enforced.
func testNoMsgCap(t *testing.T, store storage.Store) { func testNoMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
mailbox := "captain" mailbox := "captain"
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
subj := fmt.Sprintf("subject %v", i) subj := fmt.Sprintf("subject %v", i)
@@ -376,7 +411,7 @@ func testNoMsgCap(t *testing.T, store storage.Store) {
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of // testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
// them. // them.
func testVisitMailboxes(t *testing.T, ds storage.Store) { func testVisitMailboxes(t *testing.T, ds storage.Store, extHost *extension.Host) {
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"} boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
for _, name := range boxes { for _, name := range boxes {
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour)) DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
@@ -409,7 +444,7 @@ func DeliverToStore(
date time.Time, date time.Time,
) (string, int64) { ) (string, int64) {
t.Helper() t.Helper()
meta := message.Metadata{ meta := event.MessageMetadata{
Mailbox: mailbox, Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}}, To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"}, From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
+12
View File
@@ -0,0 +1,12 @@
Mailbox: ip4recipient
From: <fromuser@inbucket.org>
To: [<ip4recipient@[192.168.123.123]>]
Subject: basic subject
Size: 198
BODY TEXT:
No-To message.
BODY HTML:
+12
View File
@@ -0,0 +1,12 @@
Mailbox: ip6recipient
From: <fromuser@inbucket.org>
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
Subject: basic subject
Size: 227
BODY TEXT:
No-To message.
BODY HTML:
+4
View File
@@ -0,0 +1,4 @@
From: fromuser@inbucket.org
Subject: basic subject
No-To message.
+26 -7
View File
@@ -1,19 +1,38 @@
with import <nixpkgs> {}; { pkgs ? import <nixpkgs> { } }:
stdenv.mkDerivation rec { let
name = "env"; scripts = {
env = buildEnv { name = name; paths = buildInputs; }; # Quick test script.
buildInputs = [ qt = pkgs.writeScriptBin "qt" ''
# Builds & starts inbucket, then sends it some test mail.
make build test inbucket || exit
(sleep 3; etc/swaks-tests/run-tests.sh >/dev/null) &
env INBUCKET_LOGLEVEL=debug ./inbucket
'';
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
act
dpkg dpkg
delve
elmPackages.elm elmPackages.elm
elmPackages.elm-analyse elmPackages.elm-analyse
elmPackages.elm-format elmPackages.elm-format
elmPackages.elm-json elmPackages.elm-json
elmPackages.elm-language-server elmPackages.elm-language-server
elmPackages.elm-test elmPackages.elm-test
go go_1_18
golint golint
nodejs-14_x gopls
nodejs-16_x
nodePackages.yarn
rpm rpm
swaks swaks
scripts.qt
]; ];
# Prevents launch errors with delve debugger.
hardeningDisable = [ "fortify" ];
} }
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@parcel/config-default",
"namers": [ "parcel-namer-rewrite" ]
}
+12
View File
@@ -0,0 +1,12 @@
{
"/api": {
"target": "http://localhost:9000",
"ws": true
},
"/debug": {
"target": "http://localhost:9000"
},
"/serve": {
"target": "http://localhost:9000"
}
}
+7 -7
View File
@@ -11,9 +11,8 @@ One time setup (assuming [Node.js] is already installed):
``` ```
cd $INBUCKET/ui cd $INBUCKET/ui
npm i elm -g yarn install
npm i yarn build
npm run build
``` ```
This will the create `node_modules`, `elm-stuff`, and `dist` directories. This will the create `node_modules`, `elm-stuff`, and `dist` directories.
@@ -30,15 +29,16 @@ Inbucket will start, with HTTP listening on port 9000. You may verify the web
UI is functional if this is your first time building Inbucket, but your dev/test UI is functional if this is your first time building Inbucket, but your dev/test
cycle should favor the development server below. cycle should favor the development server below.
### Terminal 2: webpack development server ### Terminal 2: parcel development server
``` ```
cd $INBUCKET/ui cd $INBUCKET/ui
npm run dev yarn start
``` ```
npm will start a development HTTP server listening on port 3000. You should use yarn will start a development HTTP server listening on port 1234. You should
this server for UI development, as it features hot reload and the Elm debugger. use this server for UI development, as it features hot reload and the Elm
debugger.
[Elm]: https://elm-lang.org [Elm]: https://elm-lang.org
[Node.js]: https://nodejs.org [Node.js]: https://nodejs.org
-7849
View File
File diff suppressed because it is too large Load Diff
+19 -20
View File
@@ -4,30 +4,29 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rm -rf dist elm-stuff", "build": "parcel build --public-url ./",
"build": "webpack --mode production", "start": "parcel --hmr-port 1235 src/index-dev.html",
"watch": "webpack --mode development --watch", "clean": "rm -rf .parcel-cache dist elm-stuff"
"dev": "webpack-dev-server --mode development --port 3000 --hot --watch",
"errors": "webpack --mode development --display-error-details"
}, },
"dependencies": { "source": "src/index.html",
"opensans-npm-webfont": "^1.0.0" "parcel-namer-rewrite": {
"rules": {
"(.*)\\.(css|js|json|eot|png|svg|ttf|webmanifest|woff|woff2)": "static/$1{.hash}.$2"
}
}, },
"browserslist": "defaults",
"dependencies": {},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.14.8",
"@babel/preset-env": "^7.14.8",
"@fortawesome/fontawesome-free": "^5.15.3", "@fortawesome/fontawesome-free": "^5.15.3",
"@parcel/packager-raw-url": "2.4.1",
"@parcel/transformer-elm": "^2.2.1",
"@parcel/transformer-webmanifest": "2.4.1",
"@webcomponents/webcomponentsjs": "^2.5.0", "@webcomponents/webcomponentsjs": "^2.5.0",
"babel-loader": "^8.2.2", "opensans-npm-webfont": "^1.0.0",
"css-loader": "^4.3.0", "parcel": "^2.4.1",
"elm-hot-webpack-loader": "^1.1.8", "parcel-namer-rewrite": "^2.0.0-rc.2"
"elm-webpack-loader": "^7.0.1", },
"file-loader": "^6.2.0", "optionalDependencies": {
"html-webpack-plugin": "^4.5.2", "elm": "^0.19.1-5"
"node-elm-compiler": "^5.0.6",
"style-loader": "^1.3.0",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
} }
} }
+14 -4
View File
@@ -127,7 +127,7 @@ markMessageSeen session msg mailboxName id =
monitorUri : Session -> String monitorUri : Session -> String
monitorUri session = monitorUri session =
apiV1Url session [ "monitor", "messages" ] apiV2Url session [ "monitor", "messages" ]
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
@@ -135,14 +135,24 @@ purgeMailbox session msg mailboxName =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ]) HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
apiV1Url : Session -> List String -> String
apiV1Url =
apiUrl "v1"
apiV2Url : Session -> List String -> String
apiV2Url =
apiUrl "v2"
{-| Builds a public REST API URL (see wiki). {-| Builds a public REST API URL (see wiki).
-} -}
apiV1Url : Session -> List String -> String apiUrl : String -> Session -> List String -> String
apiV1Url session elements = apiUrl version session elements =
Url.Builder.absolute Url.Builder.absolute
(List.concat (List.concat
[ splitBasePath session.config.basePath [ splitBasePath session.config.basePath
, [ "api", "v1" ] , [ "api", version ]
, elements , elements
] ]
) )
+44
View File
@@ -0,0 +1,44 @@
module Data.MonitorEvent exposing (MessageID, MonitorEvent(..), decoder)
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Json.Decode exposing (Decoder, andThen, fail, field, string, succeed)
import Json.Decode.Pipeline exposing (required)
type alias MessageID =
{ mailbox : String
, id : String
}
type MonitorEvent
= MessageStored MessageHeader
| MessageDeleted MessageID
decoder : Decoder MonitorEvent
decoder =
field "variant" string
|> andThen variantDecoder
variantDecoder : String -> Decoder MonitorEvent
variantDecoder variant =
case variant of
"message-deleted" ->
succeed MessageDeleted
|> required "identifier" messageIdDecoder
"message-stored" ->
succeed MessageStored
|> required "header" MessageHeader.decoder
unknown ->
fail <| "Unknown variant: " ++ unknown
messageIdDecoder : Decoder MessageID
messageIdDecoder =
succeed MessageID
|> required "mailbox" string
|> required "id" string
+19 -6
View File
@@ -1,7 +1,8 @@
module Page.Monitor exposing (Model, Msg, init, update, view) module Page.Monitor exposing (Model, Msg, init, update, view)
import Api import Api
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader exposing (MessageHeader)
import Data.MonitorEvent as MonitorEvent
import Data.Session exposing (Session) import Data.Session exposing (Session)
import DateFormat as DF import DateFormat as DF
import Effect exposing (Effect) import Effect exposing (Effect)
@@ -68,11 +69,23 @@ update msg model =
( { model | connected = False }, Effect.none ) ( { model | connected = False }, Effect.none )
MessageReceived value -> MessageReceived value ->
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of case D.decodeValue (MonitorEvent.decoder |> D.at [ "detail" ]) value of
Ok header -> Ok event ->
( { model | messages = header :: List.take 500 model.messages } case event of
, Effect.none MonitorEvent.MessageDeleted deleted ->
) ( { model
| messages =
List.filter
(\x -> x.mailbox /= deleted.mailbox || x.id /= deleted.id)
model.messages
}
, Effect.none
)
MonitorEvent.MessageStored header ->
( { model | messages = header :: List.take 500 model.messages }
, Effect.none
)
Err err -> Err err ->
let let

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,15 +1,20 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<!-- This index file will be served by the webpack development server. --> <!-- This index file will be served by the development server. -->
<base href="/"> <base href="/">
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0">
<link rel="stylesheet" href="./main.css">
<link rel="stylesheet" href="./navbar.css">
<link rel="stylesheet" href="./mailbox.css">
<link rel="icon" type="image/png" href="./favicon.png">
<link rel="manifest" href="./manifest.json">
<title>Inbucket</title> <title>Inbucket</title>
</head> </head>
<body> <body>
@@ -17,5 +22,6 @@
You need to enable JavaScript to run this app. You need to enable JavaScript to run this app.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.js"></script>
</body> </body>
</html> </html>
+9 -3
View File
@@ -6,9 +6,14 @@
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0">
<link rel="stylesheet" href="./main.css">
<link rel="stylesheet" href="./navbar.css">
<link rel="stylesheet" href="./mailbox.css">
<link rel="icon" type="image/png" href="./favicon.png">
<link rel="manifest" href="./manifest.json">
<title>Inbucket</title> <title>Inbucket</title>
</head> </head>
<body> <body>
@@ -16,5 +21,6 @@
You need to enable JavaScript to run this app. You need to enable JavaScript to run this app.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.js"></script>
</body> </body>
</html> </html>
-3
View File
@@ -1,6 +1,3 @@
import './main.css'
import './mailbox.css'
import './navbar.css'
import '@fortawesome/fontawesome-free/css/all.css' import '@fortawesome/fontawesome-free/css/all.css'
import '@webcomponents/webcomponentsjs/webcomponents-bundle' import '@webcomponents/webcomponentsjs/webcomponents-bundle'
import 'opensans-npm-webfont' import 'opensans-npm-webfont'
-84
View File
@@ -1,84 +0,0 @@
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
module.exports = (env, argv) => {
const production = argv.mode === 'production'
const config = {
output: {
filename: 'static/[name].[hash:8].js',
publicPath: '',
},
module: {
rules: [
{
test: /\.js$/,
exclude: [/elm-stuff/, /node_modules/],
loader: 'babel-loader',
query: {
presets: [
'@babel/preset-env',
],
},
},
{
test: /\.elm$/,
exclude: [/elm-stuff/, /node_modules/],
use: [
{ loader: 'elm-hot-webpack-loader' },
{
loader: 'elm-webpack-loader',
options: {
debug: !production,
optimize: production,
},
},
],
},
{
test: /\.css$/,
loader: ['style-loader', 'css-loader'],
},
{
test: /\.(eot|svg|ttf|woff|woff2)$/,
loader: 'file-loader',
options: {
name: 'static/[name].[hash:8].[ext]',
},
},
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'public/index.html',
favicon: 'public/favicon.png',
}),
new HtmlWebpackPlugin({
filename: 'index-dev.html',
template: 'public/index-dev.html',
favicon: 'public/favicon.png',
}),
],
devServer: {
historyApiFallback: {
index: '/index-dev.html',
},
index: 'index-dev.html',
inline: true,
overlay: true,
open: true,
proxy: [{
context: ['/api', '/debug', '/serve'],
target: 'http://localhost:9000',
ws: true,
}],
stats: { colors: true },
watchOptions: {
ignored: /node_modules/,
},
},
}
if (argv.hot) {
config.plugins.push(new webpack.HotModuleReplacementPlugin())
}
return config
}
+2266
View File
File diff suppressed because it is too large Load Diff