1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 17:47:03 +00:00

Compare commits

...

84 Commits

Author SHA1 Message Date
James Hillyerd
6eff554469 chore: release 3.1.1 (#590)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-12-06 10:18:15 -08:00
James Hillyerd
7413d06616 chore: Remove broken windows arm7 build (#589)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-12-06 10:12:11 -08:00
dependabot[bot]
22c276ea1a build(deps): bump actions/checkout from 5 to 6 (#588)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 20:41:59 -08:00
dependabot[bot]
dd22202aea build(deps): bump golangci/golangci-lint-action from 8 to 9 (#583)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 12:18:57 -08:00
James Hillyerd
32b0ff1ac6 chore: Update Go version to 1.25 in release workflow (#587) 2025-11-22 12:01:01 -08:00
dependabot[bot]
e0824eb0aa build(deps): bump actions/setup-node from 4 to 6 (#582)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 11:44:05 -08:00
dependabot[bot]
f210b4c47c build(deps): bump actions/setup-go from 5 to 6 (#580)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 11:38:57 -08:00
James Hillyerd
2ea0639509 chore: Update Go version in lint workflow to 1.25 (#586) 2025-11-22 11:35:33 -08:00
dependabot[bot]
4399d02f0b build(deps): bump actions/checkout from 4 to 5 (#578)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 10:42:57 -08:00
dependabot[bot]
25007f4506 build(deps): bump js-yaml from 4.1.0 to 4.1.1 in /ui (#584)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 10:42:34 -08:00
James Hillyerd
fe0e3a00e1 chore: bump go to 1.25 (#585)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-11-22 10:41:48 -08:00
James Hillyerd
577a329240 Changelog for v3.1.0 (#577)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-07-27 12:14:00 -07:00
James Hillyerd
c3a8eb8e3b fix: Note missing script is not an error (#575)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-07-27 11:33:04 -07:00
James Hillyerd
273c6a5dbd fix: context related lints (#576)
* fix: POP3 server now uses TLS HandshakeContext

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

* fix: Web server now uses Listen with context

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

* fix: replace interface{} with any

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-07-27 11:25:03 -07:00
James Hillyerd
f799e3debf chore: modernize range loops (#574)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-31 12:57:47 -07:00
James Hillyerd
8a1a01660c chore: update goreleaser deprecated formats (#573)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-10 12:56:16 -07:00
dependabot[bot]
d1f2ae7946 build(deps): bump base-x from 3.0.9 to 3.0.11 in /ui (#564)
Bumps [base-x](https://github.com/cryptocoinjs/base-x) from 3.0.9 to 3.0.11.
- [Commits](https://github.com/cryptocoinjs/base-x/compare/v3.0.9...v3.0.11)

---
updated-dependencies:
- dependency-name: base-x
  dependency-version: 3.0.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 08:16:15 -07:00
dependabot[bot]
8339cb5378 build(deps): bump golangci/golangci-lint-action from 7 to 8 (#565)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v6...v8)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-09 07:39:25 -07:00
James Hillyerd
cf92969719 chore: Update golangci lint to 2.0.x (#572)
* Update to golangci lint 2.0.x

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

* Fix new lint warnings

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-08 21:23:42 -07:00
James Hillyerd
9a2b0f934a chore: Update Go deps (#571)
* chore: Update Go deps

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

* Fix lint warnings for loopvars

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-08 20:53:25 -07:00
James Hillyerd
b99cf9b6dc chore: Build with Go 1.24 (#568)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-08 20:41:54 -07:00
James Hillyerd
f6d00dfcb2 Update expired linters (#569)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2025-05-08 20:37:42 -07:00
Steve Atkins
440fddfe46 Add the 821.From / return-path of an email to the stored message (#560)
as a Return-Path: header. This is visible in the source view and as a
header via the REST API.

Signed-off-by: Steve Atkins <steve@wordtothewise.com>
2025-03-18 08:28:31 -07:00
Steve Atkins
9904399d24 Accept and handle emails sent with an empty 821.From / return-path as… (#561)
* Accept and handle emails sent with an empty 821.From / return-path as it would any other email.
2025-03-17 08:54:30 -07:00
Sander
4c8c8e7744 Update README.md (#556)
I copied the command from the website at [/packages/docker.html](https://inbucket.org/packages/docker.html). I thought it would be convenient if people could directly copy the command to run the image locally from the readme.
2025-02-17 09:13:57 -08:00
James Hillyerd
bd51662ce8 release 3.1.0-beta3 (#554)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-11-02 13:08:23 -07:00
James Hillyerd
7d396a6bff chore: update CHANGELOG.md (#553)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-11-02 13:00:25 -07:00
James Hillyerd
b91a681ac0 chore: Modernize Docker ENV statements (#552)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-20 16:53:44 -07:00
James Hillyerd
9471035a59 chore: Build with Go 1.23 (#551)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-20 13:05:39 -07:00
James Hillyerd
5902189187 fix: AfterMessageStored message.size (#550)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-20 12:32:51 -07:00
James Hillyerd
15d1970dbe feat: Add RemoteAddr to SMTPSession (#548)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-20 11:49:55 -07:00
James Hillyerd
78d4c4f4e7 chore: Update BeforeMailAccepted (#547)
* chore: rename BeforeMailAccepted to BeforeMailFromAccepted

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

* chore: update BeforeMailAccepted to use SMTPSession

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-19 14:06:51 -07:00
James Hillyerd
9f90a59bef feat: Add SMTPSession and BeforeRcptToAccepted event (#541)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-13 15:29:50 -07:00
James Hillyerd
3110183a17 feat: Add SMTPResponse type for extensions (#539)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-05 18:16:49 -07:00
James Hillyerd
8097b3cc8a fix: ls.Get calls use top-relative index (#537)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-10-04 21:26:31 -07:00
dependabot[bot]
35549d9bf1 build(deps): bump micromatch from 4.0.5 to 4.0.8 in /ui (#527)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-29 13:19:51 -07:00
dependabot[bot]
6a679bcbc0 build(deps): bump braces from 3.0.2 to 3.0.3 in /ui (#518)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-29 13:01:11 -07:00
James Hillyerd
81bc7c2ea7 chore: test build UI outside of release (#534)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-29 12:49:58 -07:00
James Hillyerd
f140cf7989 chore: update goreleaser to v2 (#533)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-29 12:11:14 -07:00
James Hillyerd
5284171dc5 chore: create Lua test helper (#532)
* Create lua test helper

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

* Assert labels

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-29 11:46:01 -07:00
James Hillyerd
b1b7e4b07c chore: use enmime 2.0 (#531)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-28 12:03:01 -07:00
James Hillyerd
cdff6ea571 chore: bump go deps (#529)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-28 11:54:31 -07:00
James Hillyerd
95ec463f26 chore: fix linter warnings (#530)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-09-28 11:49:32 -07:00
dependabot[bot]
924fb46b4e build(deps): bump goreleaser/goreleaser-action from 5 to 6 (#513)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 5 to 6.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-28 11:45:55 -07:00
dependabot[bot]
504a79aef4 build(deps): bump docker/build-push-action from 5 to 6 (#521)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-30 08:49:09 -07:00
James Hillyerd
543c2afda5 chore: refactor proto tests to use ReadDotLines (#520)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-06-13 17:30:44 -07:00
James Hillyerd
daeba2d024 chore: disable deprecated execinquery linter (#519)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-06-13 12:44:33 -07:00
James Hillyerd
b16764a65d fix(pop3): Prevent STLS cmd triggered crashes (#516)
* fix(pop3): Prevent STLS cmd triggered crashes

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

* err lint fix

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-06-13 12:38:07 -07:00
James Hillyerd
0df07fc1be fix: skip-pkg-cache no longer supported by golangci-lint (#517)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-06-13 12:22:14 -07:00
dependabot[bot]
9478098c0f build(deps): bump golangci/golangci-lint-action from 4 to 6 (#511)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 4 to 6.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v4...v6)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-13 21:09:11 -07:00
dependabot[bot]
658506bb11 build(deps): bump golang.org/x/net from 0.20.0 to 0.23.0 (#509)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.20.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.20.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 11:26:02 -07:00
Roberto Rossetti
8826b8342b fix(ui): date-format version (#508)
Signed-off-by: Roberto Rossetti <robertorossetti3.14@gmail.com>
2024-04-22 11:05:18 -07:00
James Hillyerd
ffb4ce0b1b actions: configure golangci linters (#474)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-20 13:00:02 -08:00
James Hillyerd
2b174c8b0b chore: resolve error & string related lint warnings (#507)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-20 12:47:27 -08:00
James Hillyerd
5729a212ce chore: two small lint fixes (#506)
* chore: faster hash to string conv

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

* chore: require NoError in integration test setup

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-20 12:30:04 -08:00
James Hillyerd
ed4a83a2bd chore: migrate integration to testify/suite (#505)
* fix: future naming collision, suite -> storeSuite

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

* chore: migrate integration to testify/suite

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-19 18:45:55 -08:00
James Hillyerd
c59e793775 chore: refactor smtp/handler if-else chain (#504)
* chore: convert smtp/handler if-else chain to switch-case

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

* chore: extract long case into parseMailCmd func

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

* chore: remove extraneous braces in cases

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-19 17:30:37 -08:00
dependabot[bot]
40ec108daf build(deps): bump actions/checkout from 3 to 4 (#499)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-19 17:27:32 -08:00
dependabot[bot]
185018e001 build(deps): bump docker/setup-qemu-action from 2 to 3 (#501)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-19 17:19:07 -08:00
James Hillyerd
d62a0fede9 fix: prevent smtp/handler test from freezing on panic (#503)
* chore: colocate SMTP session WaitGroup incr/decr

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

* fix: smtp tests that hang on panic/t.Fatal

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

* chore: reorder smtp/handler test helpers

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-19 16:46:33 -08:00
James Hillyerd
25c6f58535 chore: fix testutils linter warnings (#502)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-19 12:02:06 -08:00
dependabot[bot]
e4ca20e471 build(deps): bump goreleaser/goreleaser-action from 4 to 5 (#500)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 4 to 5.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-19 12:01:24 -08:00
dependabot[bot]
7fa6b38b38 build(deps): bump docker/metadata-action from 4 to 5 (#498)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-19 11:57:41 -08:00
dependabot[bot]
13ac9a6a1c build(deps): bump docker/build-push-action from 4 to 5 (#497)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-19 11:56:09 -08:00
James Hillyerd
0a51641a30 feature: Context support for REST client (#496)
* rest/client: add WithContext methods

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

* cmd/client: pass context

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-17 19:25:01 -08:00
James Hillyerd
73203c6bcd fstore: remove redundant test helper (#495)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-17 18:18:30 -08:00
James Hillyerd
6066be831c chore: refactor playSession etc to use t.Fatal (#494)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-16 17:04:35 -08:00
James Hillyerd
33784cbb94 chore: more small lint/perf fixes (#493)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-16 16:53:50 -08:00
James Hillyerd
f76b93a8f2 storage_suite: refactor to use common struct param (#492)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-16 16:11:07 -08:00
James Hillyerd
0361e971e0 chore: many small lint/perf fixes (#491)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-16 14:27:04 -08:00
James Hillyerd
def3e88651 luahost: use sentinel ErrNoScript (#490)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-16 14:16:17 -08:00
James Hillyerd
8adae023dc chore: rework client example to omit log.Fatal, breaks defer (#489)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 19:13:03 -08:00
James Hillyerd
fc8ea530bb chore: fix many unit test style warnings (#488)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 18:52:50 -08:00
James Hillyerd
ea585c4851 chore: fix more capitalization style warnings (#487)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 18:31:43 -08:00
James Hillyerd
baa2dbd3a1 chore: fix many cosmetic linter warnings (#486)
* fix whitespace warnings

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

* fix a number of typos

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

* fix many cosmetic linter warnings

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 18:02:26 -08:00
dependabot[bot]
864a2ba2d2 build(deps): bump actions/setup-node from 3 to 4 (#483)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-15 16:53:53 -08:00
dependabot[bot]
b586ebe210 build(deps): bump actions/setup-go from 3 to 5 (#482)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-15 16:35:56 -08:00
dependabot[bot]
bed49706fc build(deps): bump golangci/golangci-lint-action from 3 to 4 (#481)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 3 to 4.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-15 16:28:23 -08:00
James Hillyerd
6ce1fd6347 actions: lint on push to any branch (#484)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 16:19:10 -08:00
dependabot[bot]
3c0f253820 build(deps): bump docker/setup-buildx-action from 2 to 3 (#480)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-15 15:46:03 -08:00
dependabot[bot]
b2a77ad522 build(deps): bump docker/login-action from 2 to 3 (#479)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-15 15:44:03 -08:00
James Hillyerd
32cc4fc56d actions: add dependabot config (#478)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-15 15:42:26 -08:00
corey-aloia
3112deb3e6 [Rest Client] changing to enable relative urls (#477)
* changing to enable relative urls

Signed-off-by: Corey Aloia <corey.aloia@sap.com>
2024-02-09 08:53:44 -08:00
James Hillyerd
975cb4ca5e CHANGELOG: tweak release process (#473)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-05 13:06:27 -08:00
91 changed files with 2225 additions and 1292 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
---
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -10,28 +10,23 @@ jobs:
linux-go-build:
runs-on: ubuntu-latest
name: Linux Go ${{ matrix.go }} build
strategy:
matrix:
go:
- '1.21'
- '1.25'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
check-latest: true
- name: Build and test
run: |
go build ./...
go test -race -coverprofile=profile.cov ./...
- name: Send coverage
uses: shogo82148/actions-goveralls@v1
with:
@@ -42,23 +37,18 @@ jobs:
windows-go-build:
runs-on: windows-latest
name: Windows Go build
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: '1.25'
- name: Build
run: go build ./...
- name: Test
run: go test -race -coverprofile="profile.cov" ./...
- name: Send coverage
uses: shogo82148/actions-goveralls@v1
with:
@@ -66,13 +56,32 @@ jobs:
flag-name: Windows-Go
parallel: true
ui-build:
runs-on: ubuntu-latest
name: UI Build
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
cache: 'yarn'
cache-dependency-path: ui/yarn.lock
- name: Build frontend
run: |
yarn install --frozen-lockfile --non-interactive
yarn run build
working-directory: ./ui
coverage:
needs:
- linux-go-build
- windows-go-build
name: Test Coverage
runs-on: ubuntu-latest
steps:
- uses: shogo82148/actions-goveralls@v1
with:

View File

@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
inbucket/inbucket
@@ -39,30 +39,30 @@ jobs:
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64, linux/arm/v7

View File

@@ -2,22 +2,16 @@ name: Lint Go Code
on:
push:
branches:
- main
pull_request:
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: '1.25'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v9
with:
version: latest
# Disable cache to prevent `File exists` errors.
# https://github.com/golangci/golangci-lint-action/issues/135
skip-pkg-cache: true

View File

@@ -6,25 +6,25 @@ on:
- main
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
release:
name: 'Go Releaser'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: '1.25'
check-latest: true
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v6
with:
node-version: '20.x'
cache: 'yarn'
@@ -37,17 +37,19 @@ jobs:
working-directory: ./ui
- name: Test build release
uses: goreleaser/goreleaser-action@v4
uses: goreleaser/goreleaser-action@v6
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
distribution: goreleaser
version: "~> v2"
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v4
uses: goreleaser/goreleaser-action@v6
if: "startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
distribution: goreleaser
version: "~> v2"
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

82
.golangci.yml Normal file
View File

@@ -0,0 +1,82 @@
version: "2"
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- containedctx
- contextcheck
- copyloopvar
- decorder
- durationcheck
- errchkjson
- errname
- ginkgolinter
- gocheckcompilerdirectives
- gochecksumtype
- gocritic
- goheader
- gomoddirectives
- gomodguard
- goprintffuncname
- gosmopolitan
- grouper
- importas
- inamedparam
- interfacebloat
- loggercheck
- makezero
- mirror
- misspell
- musttag
- nilerr
- noctx
- nolintlint
- nosprintfhostport
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- rowserrcheck
- sloglint
- staticcheck
- tagliatelle
- testableexamples
- testifylint
- thelper
- tparallel
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- zerologlint
settings:
tagliatelle:
case:
rules:
json: kebab
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,3 +1,4 @@
version: 2 # goreleaser version
project_name: inbucket
release:
@@ -26,6 +27,9 @@ builds:
- arm64
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- id: inbucket-client
@@ -43,16 +47,19 @@ builds:
- arm64
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/client
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archives:
- id: tarball
format: tar.gz
formats: tar.gz
wrap_in_directory: true
format_overrides:
- goos: windows
format: zip
formats: zip
files:
- LICENSE*
- README*

9
.luarc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"runtime.version": "Lua 5.1",
"diagnostics": {
"globals": [
"inbucket",
"smtp"
]
}
}

View File

@@ -4,6 +4,42 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v3.1.1] - 2025-12-06
### Fixed
- Go version update for CVE-2025-47907
- Removed broken Windows arm7 build (#589)
## [v3.1.0] - 2025-07-27
### Added
- Note in logs that a missing Lua script is not an error (#575)
### Fixed
- Accept and handle emails sent with an empty 821.From (#561)
## [v3.1.0-beta3] - 2024-11-02
### Added
- Lua scripting additions:
- Add `SMTPSession` and `BeforeRcptToAccepted` event (#541)
- Add `SMTPResponse` type for extensions (#539)
- Add `RemoteAddr` to `SMTPSession` (#548)
- Context support for REST client (#496)
### Fixed
- Rename Lua `BeforeMailAccepted`, change args (#547)
- pop3: Prevent STLS cmd triggered crashes (#516)
- ui: date-format version, fixes yarn build (#508)
- rework client example to omit `log.Fatal`, breaks defer (#489)
- Rest Client: Allow relative URLs (#477)
## [v3.1.0-beta2] - 2024-02-05
### Added
@@ -350,7 +386,10 @@ No change from beta1.
specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...main
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.1...main
[v3.1.1]: https://github.com/inbucket/inbucket/compare/v3.1.0...v3.1.1
[v3.1.0]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta3...v3.1.0
[v3.1.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...v3.1.0-beta3
[v3.1.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...v3.1.0-beta2
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
@@ -388,10 +427,11 @@ No change from beta1.
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
3. Run tests
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release into main, tag `vX.Y.Z`
6. Push tags and wait for
4. Update goreleaser, and then test cross-compile: `goreleaser release --snapshot --clean`
5. Commit changes and merge release PR into main
6. Create new release via GitHub, use CHANGELOG release notes, tag `vX.Y.Z`
7. Push tags and wait for
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
-- it will add compiled release assets
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -2,7 +2,7 @@
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 as frontend
FROM --platform=linux/amd64 node:20 AS frontend
RUN npm install -g node-gyp
WORKDIR /build
COPY . .
@@ -12,18 +12,18 @@ RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.21-alpine3.19 as backend
FROM golang:1.25-alpine3.22 AS backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
ENV CGO_ENABLED=0
RUN make clean deps
RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
### Run in minimal image
FROM alpine:3.19
FROM alpine:3.22
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
@@ -33,16 +33,16 @@ COPY etc/docker/defaults/greeting.html defaults
COPY etc/docker/defaults/start-inbucket.sh /
# Configuration
ENV INBUCKET_SMTP_DISCARDDOMAINS bitbucket.local
ENV INBUCKET_SMTP_TIMEOUT 30s
ENV INBUCKET_POP3_TIMEOUT 30s
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR ui
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
ENV INBUCKET_SMTP_DISCARDDOMAINS=bitbucket.local
ENV INBUCKET_SMTP_TIMEOUT=30s
ENV INBUCKET_POP3_TIMEOUT=30s
ENV INBUCKET_WEB_GREETINGFILE=/config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY=secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR=ui
ENV INBUCKET_STORAGE_TYPE=file
ENV INBUCKET_STORAGE_PARAMS=path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD=72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP=300
# Healthcheck
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'

View File

@@ -30,6 +30,13 @@ Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
tracks our tagged releases, and `edge` tracks our potentially unstable
`main` branch.
Start the docker image by running:
```
docker run -d --name inbucket -p 9000:9000 -p 2500:2500 -p 1100:1100 inbucket/inbucket
```
Then point your browser to [localhost:9000](http://localhost:9000/)
## Building from Source

View File

@@ -28,18 +28,20 @@ func (*listCmd) Usage() string {
func (l *listCmd) SetFlags(f *flag.FlagSet) {}
func (l *listCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup rest client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("REST call failed", err)
}

View File

@@ -5,8 +5,10 @@ import (
"context"
"flag"
"fmt"
"net"
"os"
"regexp"
"strconv"
"github.com/google/subcommands"
)
@@ -68,7 +70,7 @@ func main() {
}
func baseURL() string {
return fmt.Sprintf("http://%s:%v", *host, *port)
return "http://%s" + net.JoinHostPort(*host, strconv.FormatUint(uint64(*port), 10))
}
func fatal(msg string, err error) subcommands.ExitStatus {

View File

@@ -15,7 +15,7 @@ import (
type matchCmd struct {
output string
outFunc func(headers []*client.MessageHeader) error
outFunc func(ctx context.Context, headers []*client.MessageHeader) error
delete bool
// match criteria
from regexFlag
@@ -51,11 +51,12 @@ func (m *matchCmd) SetFlags(f *flag.FlagSet) {
}
func (m *matchCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Select output function
switch m.output {
case "id":
@@ -67,16 +68,19 @@ func (m *matchCmd) Execute(
default:
return usage("unknown output type: " + m.output)
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
// Find matches
matches := make([]*client.MessageHeader, 0, len(headers))
for _, h := range headers {
@@ -84,24 +88,28 @@ func (m *matchCmd) Execute(
matches = append(matches, h)
}
}
// Return error status if no matches
if len(matches) == 0 {
return subcommands.ExitFailure
}
// Output matches
err = m.outFunc(matches)
err = m.outFunc(ctx, matches)
if err != nil {
return fatal("Error", err)
}
// Optionally, delete matches
if m.delete {
// Delete matches
for _, h := range matches {
err = h.Delete()
err = h.DeleteWithContext(ctx)
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
@@ -148,14 +156,14 @@ func (m *matchCmd) match(header *client.MessageHeader) bool {
return true
}
func outputID(headers []*client.MessageHeader) error {
func outputID(_ context.Context, headers []*client.MessageHeader) error {
for _, h := range headers {
fmt.Println(h.ID)
}
return nil
}
func outputJSON(headers []*client.MessageHeader) error {
func outputJSON(_ context.Context, headers []*client.MessageHeader) error {
jsonEncoder := json.NewEncoder(os.Stdout)
jsonEncoder.SetEscapeHTML(false)
jsonEncoder.SetIndent("", " ")

View File

@@ -33,44 +33,48 @@ func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
}
func (m *mboxCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
err = outputMbox(headers)
err = outputMbox(ctx, headers)
if err != nil {
return fatal("Error", err)
}
// Optionally, delete retrieved messages
if m.delete {
// Delete matches
for _, h := range headers {
err = h.Delete()
err = h.DeleteWithContext(ctx)
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
// outputMbox renders messages in mbox format
// also used by match subcommand
func outputMbox(headers []*client.MessageHeader) error {
// outputMbox renders messages in mbox format.
// It is also used by match subcommand.
func outputMbox(ctx context.Context, headers []*client.MessageHeader) error {
for _, h := range headers {
source, err := h.GetSource()
source, err := h.GetSourceWithContext(ctx)
if err != nil {
return fmt.Errorf("Get source REST failed: %v", err)
return fmt.Errorf("get source REST failed: %v", err)
}
fmt.Printf("From %s\n", h.From)

View File

@@ -37,7 +37,7 @@ func init() {
startTime.Set(time.Now().UnixNano() / 1000000)
// Goroutine count for status page.
expvar.Publish("goroutines", expvar.Func(func() interface{} {
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
@@ -162,7 +162,7 @@ signalLoop:
}
// openLog configures zerolog output, returns func to close logfile.
func openLog(level string, logfile string, json bool) (close func(), err error) {
func openLog(level string, logfile string, json bool) (closeLog func(), err error) {
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
@@ -173,9 +173,10 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
return nil, fmt.Errorf("log level %q not one of: debug, info, warn, error", level)
}
close = func() {}
closeLog = func() {}
var w io.Writer
color := runtime.GOOS != "windows"
switch logfile {
@@ -191,21 +192,24 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
bw := bufio.NewWriter(logf)
w = bw
color = false
close = func() {
closeLog = func() {
_ = bw.Flush()
_ = logf.Close()
}
}
w = zerolog.SyncWriter(w)
if json {
log.Logger = log.Output(w)
return close, nil
return closeLog, nil
}
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: w,
NoColor: !color,
})
return close, nil
return closeLog, nil
}
// removePIDFile removes the PID file if created.

26
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/inbucket/inbucket/v3
go 1.21
toolchain go1.21.4
go 1.25.0
require (
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
@@ -10,16 +8,16 @@ require (
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.3
github.com/inbucket/gopher-json v0.2.0
github.com/jhillyerd/enmime v1.1.0
github.com/jhillyerd/enmime/v2 v2.1.0
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/microcosm-cc/bluemonday v1.0.26
github.com/rs/zerolog v1.32.0
github.com/stretchr/testify v1.8.4
github.com/microcosm-cc/bluemonday v1.0.27
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/yuin/gopher-lua v1.1.1
golang.org/x/net v0.20.0
golang.org/x/net v0.40.0
)
require (
@@ -29,18 +27,18 @@ require (
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

49
go.sum
View File

@@ -10,8 +10,8 @@ github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDT
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
@@ -21,14 +21,14 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/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-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/jhillyerd/enmime/v2 v2.1.0 h1:c8Qwi5Xq5EdtMN6byQWoZ/8I2RMTo6OJ7Xay+s1oPO0=
github.com/jhillyerd/enmime/v2 v2.1.0/go.mod h1:EJ74dcRbBcqHSP2TBu08XRoy6y3Yx0cevwb1YkGMEmQ=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
@@ -40,17 +40,18 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -61,30 +62,30 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 h1:noHsffKZsNfU38DwcXWEPldrTjIZ8FPNKx8mYMGnqjs=
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7/go.mod h1:bbMEM6aU1WDF1ErA5YJ0p91652pGv140gGw4Ww3RGp8=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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=

View File

@@ -51,7 +51,7 @@ func (n *mbNaming) Decode(v string) error {
case "domain":
*n = DomainNaming
default:
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
return fmt.Errorf("unknown MailboxNaming strategy: %q", v)
}
return nil
}

View File

@@ -83,7 +83,7 @@ func (eb *AsyncEventBroker[E]) AsyncTestListener(name string, capacity int) func
return &event, nil
case <-time.After(time.Second * 2):
return nil, errors.New("Timeout waiting for event")
return nil, errors.New("timeout waiting for event")
}
}
}

View File

@@ -47,13 +47,13 @@ func TestAsyncBrokerEmitCallsMultipleListeners(t *testing.T) {
want := "hi"
broker.Emit(&want)
first_got, err := first()
firstGot, err := first()
require.NoError(t, err)
assert.Equal(t, want, *first_got)
assert.Equal(t, want, *firstGot)
second_got, err := second()
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
@@ -66,13 +66,13 @@ func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
want := "hi"
broker.Emit(&want)
first_got, err := first()
firstGot, err := first()
require.Error(t, err)
assert.Nil(t, first_got)
assert.Nil(t, firstGot)
second_got, err := second()
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
@@ -86,13 +86,13 @@ func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
want := "hi"
broker.Emit(&want)
first_got, err := first()
firstGot, err := first()
require.Error(t, err)
assert.Nil(t, first_got)
assert.Nil(t, firstGot)
second_got, err := second()
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *second_got)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerRemovingMissingListener(t *testing.T) {

View File

@@ -28,13 +28,13 @@ func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
var firstGot, secondGot string
first := func(s string) *bool {
first_got = s
firstGot = s
return nil
}
second := func(s string) *bool {
second_got = s
secondGot = s
return nil
}
@@ -43,11 +43,11 @@ func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
want := "hi"
broker.Emit(&want)
if first_got != want {
t.Errorf("first got %q, want %q", first_got, want)
if firstGot != want {
t.Errorf("first got %q, want %q", firstGot, want)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}
@@ -77,13 +77,13 @@ func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
var firstGot, secondGot string
first := func(s string) *bool {
first_got = s
firstGot = s
return nil
}
second := func(s string) *bool {
second_got = s
secondGot = s
return nil
}
@@ -92,11 +92,11 @@ func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
want := "hi"
broker.Emit(&want)
if first_got != "" {
t.Errorf("first got %q, want empty string", first_got)
if firstGot != "" {
t.Errorf("first got %q, want empty string", firstGot)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}
@@ -104,13 +104,13 @@ func TestBrokerRemovingListenerSuccessful(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var first_got, second_got string
var firstGot, secondGot string
first := func(s string) *bool {
first_got = s
firstGot = s
return nil
}
second := func(s string) *bool {
second_got = s
secondGot = s
return nil
}
@@ -120,11 +120,11 @@ func TestBrokerRemovingListenerSuccessful(t *testing.T) {
want := "hi"
broker.Emit(&want)
if first_got != "" {
t.Errorf("first got %q, want empty string", first_got)
if firstGot != "" {
t.Errorf("first got %q, want empty string", firstGot)
}
if second_got != want {
t.Errorf("second got %q, want %q", second_got, want)
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}

View File

@@ -5,6 +5,15 @@ import (
"time"
)
const (
// ActionDefer defers decision to built-in Inbucket logic.
ActionDefer = iota
// ActionAllow explicitly allows this event.
ActionAllow
// ActionDeny explicitly deny this event, typically with specified SMTP error.
ActionDeny
)
// AddressParts contains the local and domain parts of an email address.
type AddressParts struct {
Local string
@@ -31,3 +40,17 @@ type MessageMetadata struct {
Size int64
Seen bool
}
// SMTPResponse describes the response to an SMTP policy check.
type SMTPResponse struct {
Action int // ActionDefer, ActionAllow, etc.
ErrorCode int // SMTP error code to respond with on deny.
ErrorMsg string // SMTP error message to respond with on deny.
}
// SMTPSession captures SMTP `MAIL FROM` & `RCPT TO` values prior to mail DATA being received.
type SMTPSession struct {
From *mail.Address
To []*mail.Address
RemoteAddr string
}

View File

@@ -18,12 +18,13 @@ type Host struct {
//
// 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.
// listener will not be called until the one before it completes.
type Events struct {
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
BeforeMailAccepted EventBroker[event.AddressParts, bool]
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
BeforeMailFromAccepted EventBroker[event.SMTPSession, event.SMTPResponse]
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
BeforeRcptToAccepted EventBroker[event.SMTPSession, event.SMTPResponse]
}
// Void indicates the event emitter will ignore any value returned by listeners.

View File

@@ -4,9 +4,9 @@ import (
"net/mail"
"testing"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestMailAddressGetters(t *testing.T) {
@@ -26,8 +26,9 @@ func TestMailAddressGetters(t *testing.T) {
assert(got == want, string.format("got address %q, want %q", got, want))
`
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerMailAddressType(ls)
ls.SetGlobal("addr", wrapMailAddress(ls, want))
require.NoError(t, ls.DoString(script))
}
@@ -44,9 +45,10 @@ func TestMailAddressSetters(t *testing.T) {
addr.address = "ri@example.com"
`
got := &mail.Address{}
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerMailAddressType(ls)
got := &mail.Address{}
ls.SetGlobal("addr", wrapMailAddress(ls, got))
require.NoError(t, ls.DoString(script))

View File

@@ -55,7 +55,7 @@ func unwrapInboundMessage(lv lua.LValue) (*event.InboundMessage, error) {
}
}
return nil, fmt.Errorf("Expected InboundMessage, got %q", lv.Type().String())
return nil, fmt.Errorf("expected InboundMessage, got %q", lv.Type().String())
}
// Gets a field value from InboundMessage user object. This emulates a Lua table,

View File

@@ -5,29 +5,11 @@ import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
function assert_eq(got, want)
if type(got) == "table" and type(want) == "table" then
assert(#got == #want, string.format("got %d element(s), wanted %d", #got, #want))
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
end
return
end
assert(got == want, string.format("got %q, wanted %q", got, want))
end
`
func TestInboundMessageGetters(t *testing.T) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
@@ -44,23 +26,23 @@ func TestInboundMessageGetters(t *testing.T) {
assert_eq(msg.mailboxes, {"mb1", "mb2"})
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42)
assert_eq(msg.size, 42, "msg.size")
assert_eq(msg.from.name, "name1")
assert_eq(msg.from.address, "addr1")
assert_eq(msg.from.name, "name1", "from.name")
assert_eq(msg.from.address, "addr1", "from.address")
assert_eq(#msg.to, 2)
assert_eq(msg.to[1].name, "name2")
assert_eq(msg.to[1].address, "addr2")
assert_eq(msg.to[2].name, "name3")
assert_eq(msg.to[2].address, "addr3")
assert_eq(#msg.to, 2, "#msg.to")
assert_eq(msg.to[1].name, "name2", "to[1].name")
assert_eq(msg.to[1].address, "addr2", "to[1].address")
assert_eq(msg.to[2].name, "name3", "to[2].name")
assert_eq(msg.to[2].address, "addr3", "to[2].address")
`
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
require.NoError(t, ls.DoString(LuaInit+script))
require.NoError(t, ls.DoString(script))
}
func TestInboundMessageSetters(t *testing.T) {
@@ -83,7 +65,7 @@ func TestInboundMessageSetters(t *testing.T) {
`
got := &event.InboundMessage{}
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, got))

View File

@@ -29,8 +29,9 @@ type InbucketAfterFuncs struct {
// InbucketBeforeFuncs holds references to Lua extension functions to be called
// before Inbucket handles an event.
type InbucketBeforeFuncs struct {
MailAccepted *lua.LFunction
MessageStored *lua.LFunction
MailFromAccepted *lua.LFunction
MessageStored *lua.LFunction
RcptToAccepted *lua.LFunction
}
func registerInbucketTypes(ls *lua.LState) {
@@ -185,10 +186,12 @@ func inbucketBeforeIndex(ls *lua.LState) int {
// Push the requested field's value onto the stack.
switch field {
case "mail_accepted":
ls.Push(funcOrNil(before.MailAccepted))
case "mail_from_accepted":
ls.Push(funcOrNil(before.MailFromAccepted))
case "message_stored":
ls.Push(funcOrNil(before.MessageStored))
case "rcpt_to_accepted":
ls.Push(funcOrNil(before.RcptToAccepted))
default:
// Unknown field.
ls.Push(lua.LNil)
@@ -203,10 +206,12 @@ func inbucketBeforeNewIndex(ls *lua.LState) int {
index := ls.CheckString(2)
switch index {
case "mail_accepted":
m.MailAccepted = ls.CheckFunction(3)
case "mail_from_accepted":
m.MailFromAccepted = ls.CheckFunction(3)
case "message_stored":
m.MessageStored = ls.CheckFunction(3)
case "rcpt_to_accepted":
m.RcptToAccepted = ls.CheckFunction(3)
default:
ls.RaiseError("invalid inbucket.before index %q", index)
}

View File

@@ -3,8 +3,8 @@ package luahost
import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestInbucketAfterFuncs(t *testing.T) {
@@ -49,7 +49,7 @@ func TestInbucketAfterFuncs(t *testing.T) {
end
`
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}
@@ -62,7 +62,7 @@ func TestInbucketBeforeFuncs(t *testing.T) {
assert(inbucket, "inbucket should not be nil")
assert(inbucket.before, "inbucket.before should not be nil")
local fns = { "mail_accepted", "message_stored" }
local fns = { "mail_from_accepted", "message_stored", "rcpt_to_accepted" }
-- Verify functions start off nil.
for i, name in ipairs(fns) do
@@ -96,7 +96,7 @@ func TestInbucketBeforeFuncs(t *testing.T) {
end
`
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}

View File

@@ -6,9 +6,9 @@ import (
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestMessageMetadataGetters(t *testing.T) {
@@ -24,26 +24,22 @@ func TestMessageMetadataGetters(t *testing.T) {
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.size, 42, "msg.size")
assert_eq(msg.from.name, "name1")
assert_eq(msg.from.address, "addr1")
assert_eq(msg.from.name, "name1", "from.name")
assert_eq(msg.from.address, "addr1", "from.address")
assert_eq(table.getn(msg.to), 1)
assert_eq(msg.to[1].name, "name2")
assert_eq(msg.to[1].address, "addr2")
assert_eq(msg.to[1].name, "name2", "to.name")
assert_eq(msg.to[1].address, "addr2", "to.address")
assert_eq(msg.date, 981173106)
assert_eq(msg.date, 981173106, "msg.date")
`
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
@@ -75,7 +71,7 @@ func TestMessageMetadataSetters(t *testing.T) {
`
got := &event.MessageMetadata{}
ls := lua.NewState()
ls, _ := test.NewLuaState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))

View File

@@ -1,17 +0,0 @@
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)
}

View File

@@ -0,0 +1,54 @@
package luahost
import (
"fmt"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
lua "github.com/yuin/gopher-lua"
)
const smtpResponseName = "smtp"
func registerSMTPResponseType(ls *lua.LState) {
mt := ls.NewTypeMetatable(smtpResponseName)
ls.SetGlobal(smtpResponseName, mt)
// Static attributes.
ls.SetField(mt, "allow", ls.NewFunction(newSMTPResponse(event.ActionAllow)))
ls.SetField(mt, "defer", ls.NewFunction(newSMTPResponse(event.ActionDefer)))
ls.SetField(mt, "deny", ls.NewFunction(newSMTPResponse(event.ActionDeny)))
}
func newSMTPResponse(action int) func(*lua.LState) int {
return func(ls *lua.LState) int {
val := &event.SMTPResponse{Action: action}
if action == event.ActionDeny {
// Optionally accept error code and message.
val.ErrorCode = ls.OptInt(1, 550)
val.ErrorMsg = ls.OptString(2, "Mail denied by policy")
}
ud := wrapSMTPResponse(ls, val)
ls.Push(ud)
return 1
}
}
func wrapSMTPResponse(ls *lua.LState, val *event.SMTPResponse) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(smtpResponseName))
return ud
}
func unwrapSMTPResponse(lv lua.LValue) (*event.SMTPResponse, error) {
if ud, ok := lv.(*lua.LUserData); ok {
if v, ok := ud.Value.(*event.SMTPResponse); ok {
return v, nil
}
}
return nil, fmt.Errorf("expected SMTPResponse, got %q", lv.Type().String())
}

View File

@@ -0,0 +1,40 @@
package luahost
import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSMTPResponseConstructors(t *testing.T) {
check := func(script string, want event.SMTPResponse) {
t.Helper()
ls, _ := test.NewLuaState()
registerSMTPResponseType(ls)
require.NoError(t, ls.DoString(script))
got, err := unwrapSMTPResponse(ls.Get(-1))
require.NoError(t, err)
assert.Equal(t, &want, got)
}
check("return smtp.defer()", event.SMTPResponse{Action: event.ActionDefer})
check("return smtp.allow()", event.SMTPResponse{Action: event.ActionAllow})
// Verify deny() has default code & msg.
check("return smtp.deny()", event.SMTPResponse{
Action: event.ActionDeny,
ErrorCode: 550,
ErrorMsg: "Mail denied by policy",
})
// Verify defaults can be overridden.
check("return smtp.deny(123, 'bacon')", event.SMTPResponse{
Action: event.ActionDeny,
ErrorCode: 123,
ErrorMsg: "bacon",
})
}

View File

@@ -0,0 +1,72 @@
package luahost
import (
"github.com/inbucket/inbucket/v3/pkg/extension/event"
lua "github.com/yuin/gopher-lua"
)
const smtpSessionName = "smtp_session"
func registerSMTPSessionType(ls *lua.LState) {
mt := ls.NewTypeMetatable(smtpSessionName)
ls.SetGlobal(smtpSessionName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newSMTPSession))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(smtpSessionIndex))
}
func newSMTPSession(ls *lua.LState) int {
val := &event.SMTPSession{}
ud := wrapSMTPSession(ls, val)
ls.Push(ud)
return 1
}
func wrapSMTPSession(ls *lua.LState, val *event.SMTPSession) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(smtpSessionName))
return ud
}
// Checks there is an SMTPSession at stack position `pos`, else throws Lua error.
func checkSMTPSession(ls *lua.LState, pos int) *event.SMTPSession {
ud := ls.CheckUserData(pos)
if v, ok := ud.Value.(*event.SMTPSession); ok {
return v
}
ls.ArgError(pos, smtpSessionName+" expected")
return nil
}
// Gets a field value from SMTPSession user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func smtpSessionIndex(ls *lua.LState) int {
session := checkSMTPSession(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "from":
ls.Push(wrapMailAddress(ls, session.From))
case "to":
lt := &lua.LTable{}
for _, v := range session.To {
addr := v
lt.Append(wrapMailAddress(ls, addr))
}
ls.Push(lt)
case "remote_addr":
ls.Push(lua.LString(session.RemoteAddr))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}

View File

@@ -0,0 +1,41 @@
package luahost
import (
"net/mail"
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/require"
)
func TestSMTPSessionGetters(t *testing.T) {
want := &event.SMTPSession{
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{
{Name: "name2", Address: "addr2"},
{Name: "name3", Address: "addr3"},
},
RemoteAddr: "1.2.3.4",
}
script := `
assert(session, "session should not be nil")
assert_eq(session.from.name, "name1", "from.name")
assert_eq(session.from.address, "addr1", "from.address")
assert_eq(#session.to, 2, "#session.to")
assert_eq(session.to[1].name, "name2", "to[1].name")
assert_eq(session.to[1].address, "addr2", "to[1].address")
assert_eq(session.to[2].name, "name3", "to[2].name")
assert_eq(session.to[2].address, "addr3", "to[2].address")
assert_eq(session.remote_addr, "1.2.3.4")
`
ls, _ := test.NewLuaState()
registerSMTPSessionType(ls)
registerMailAddressType(ls)
ls.SetGlobal("session", wrapSMTPSession(ls, want))
require.NoError(t, ls.DoString(script))
}

View File

@@ -2,6 +2,7 @@ package luahost
import (
"bufio"
"errors"
"fmt"
"io"
"os"
@@ -15,6 +16,9 @@ import (
"github.com/yuin/gopher-lua/parse"
)
// ErrNoScript signals that the Lua script file was not present.
var ErrNoScript error = errors.New("no script file present")
// Host of Lua extensions.
type Host struct {
extHost *extension.Host
@@ -34,10 +38,10 @@ func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
// Pre-load, parse, and compile script.
if fi, err := os.Stat(scriptPath); err != nil {
logger.Info().Msg("Script file not found")
return nil, nil
logger.Info().Msg("Lua script file not found (this is not an error)")
return nil, ErrNoScript
} else if fi.IsDir() {
return nil, fmt.Errorf("Lua script %v is a directory", scriptPath)
return nil, fmt.Errorf("lua script %v is a directory", scriptPath)
}
logger.Info().Msg("Loading script")
@@ -102,12 +106,15 @@ func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
if ib.After.MessageStored != nil {
events.AfterMessageStored.AddListener(listenerName, h.handleAfterMessageStored)
}
if ib.Before.MailAccepted != nil {
events.BeforeMailAccepted.AddListener(listenerName, h.handleBeforeMailAccepted)
if ib.Before.MailFromAccepted != nil {
events.BeforeMailFromAccepted.AddListener(listenerName, h.handleBeforeMailFromAccepted)
}
if ib.Before.MessageStored != nil {
events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored)
}
if ib.Before.RcptToAccepted != nil {
events.BeforeRcptToAccepted.AddListener(listenerName, h.handleBeforeRcptToAccepted)
}
}
func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) {
@@ -144,37 +151,60 @@ func (h *Host) handleAfterMessageStored(msg event.MessageMetadata) {
}
}
func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *bool {
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.mail_accepted")
func (h *Host) handleBeforeMailFromAccepted(session event.SMTPSession) *event.SMTPResponse {
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.mail_from_accepted")
if !ok {
return nil
}
defer h.pool.putState(ls)
logger.Debug().Msgf("Calling Lua function with %+v", addr)
logger.Debug().Msgf("Calling Lua function with %+v", session)
if err := ls.CallByParam(
lua.P{Fn: ib.Before.MailAccepted, NRet: 1, Protect: true},
lua.LString(addr.Local),
lua.LString(addr.Domain),
lua.P{Fn: ib.Before.MailFromAccepted, NRet: 1, Protect: true},
wrapSMTPSession(ls, &session),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
return nil
}
lval := ls.Get(1)
lval := ls.Get(-1)
ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
if lval.Type() == lua.LTNil {
result, err := unwrapSMTPResponse(lval)
if err != nil {
logger.Error().Err(err).Msg("Bad response from Lua Function")
}
return result
}
func (h *Host) handleBeforeRcptToAccepted(session event.SMTPSession) *event.SMTPResponse {
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.rcpt_to_accepted")
if !ok {
return nil
}
defer h.pool.putState(ls)
logger.Debug().Msgf("Calling Lua function with %+v", session)
if err := ls.CallByParam(
lua.P{Fn: ib.Before.RcptToAccepted, NRet: 1, Protect: true},
wrapSMTPSession(ls, &session),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
return nil
}
result := true
if lua.LVIsFalse(lval) {
result = false
lval := ls.Get(-1)
ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
result, err := unwrapSMTPResponse(lval)
if err != nil {
logger.Error().Err(err).Msg("Bad response from Lua Function")
}
return &result
return result
}
func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.InboundMessage {
@@ -193,10 +223,11 @@ func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.Inboun
return nil
}
lval := ls.Get(1)
lval := ls.Get(-1)
ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
if lval.Type() == lua.LTNil || lua.LVIsFalse(lval) {
if lua.LVIsFalse(lval) {
return nil
}
@@ -204,7 +235,6 @@ func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.Inboun
if err != nil {
logger.Error().Err(err).Msg("Bad response from Lua Function")
}
ls.Pop(1)
return result
}

View File

@@ -9,54 +9,12 @@ import (
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
local logger = require("logger")
async = false
test_ok = true
-- Sends marks tests as failed instead of erroring when enabled.
function assert_async(value, message)
if not value then
if async then
logger.error(message, {from = "assert_async"})
test_ok = false
else
error(message)
end
end
end
-- Verifies plain values and list-style tables.
function assert_eq(got, want)
if type(got) == "table" and type(want) == "table" then
assert_async(#got == #want, string.format("got %d elements, wanted %d", #got, #want))
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
end
return
end
assert_async(got == want, string.format("got %q, wanted %q", got, want))
end
-- Verifies string want contains string got.
function assert_contains(got, want)
assert_async(string.find(got, want),
string.format("got %q, wanted it to contain %q", got, want))
end
`
var consoleLogger = zerolog.New(zerolog.NewConsoleWriter())
func TestEmptyScript(t *testing.T) {
@@ -92,11 +50,12 @@ func TestAfterMessageDeleted(t *testing.T) {
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
notify:send(asserts_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
@@ -111,7 +70,7 @@ func TestAfterMessageDeleted(t *testing.T) {
Size: 42,
}
extHost.Events.AfterMessageDeleted.Emit(msg)
assertNotified(t, notify)
test.AssertNotified(t, notify)
}
func TestAfterMessageStored(t *testing.T) {
@@ -123,11 +82,12 @@ func TestAfterMessageStored(t *testing.T) {
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
notify:send(asserts_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
@@ -142,36 +102,51 @@ func TestAfterMessageStored(t *testing.T) {
Size: 42,
}
extHost.Events.AfterMessageStored.Emit(msg)
assertNotified(t, notify)
test.AssertNotified(t, notify)
}
func TestBeforeMailAccepted(t *testing.T) {
func TestBeforeMailFromAccepted(t *testing.T) {
// Register lua event listener.
script := `
function inbucket.before.mail_accepted(localpart, domain)
return localpart == "from" and domain == "test"
function inbucket.before.mail_from_accepted(session)
if session.from.address == "from@example.com" then
logger.info("allowing message", {})
return smtp.allow()
else
logger.info("denying message", {})
return smtp.deny()
end
end
`
extHost := extension.NewHost()
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
_, err := luahost.NewFromReader(
consoleLogger, extHost, strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
// Send event to be accepted.
addr := &event.AddressParts{Local: "from", Domain: "test"}
got := extHost.Events.BeforeMailAccepted.Emit(addr)
want := true
require.NotNil(t, got, "Expected result from Emit()")
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
{
// Send event to be accepted.
session := event.SMTPSession{
From: &mail.Address{Name: "", Address: "from@example.com"},
}
got := extHost.Events.BeforeMailFromAccepted.Emit(&session)
want := event.ActionAllow
require.NotNil(t, got, "Expected result from Emit()")
if got.Action != want {
t.Errorf("Got %v, wanted %v for addr %v", got.Action, want, session.From)
}
}
// Send event to be denied.
addr = &event.AddressParts{Local: "reject", Domain: "me"}
got = extHost.Events.BeforeMailAccepted.Emit(addr)
want = false
require.NotNil(t, got, "Expected result from Emit()")
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
{
// Send event to be denied.
session := event.SMTPSession{
From: &mail.Address{Name: "", Address: "from@reject.com"},
}
got := extHost.Events.BeforeMailFromAccepted.Emit(&session)
want := event.ActionDeny
require.NotNil(t, got, "Expected result from Emit()")
if got.Action != want {
t.Errorf("Got %v, wanted %v for addr %v", got.Action, want, session.From)
}
}
}
@@ -197,14 +172,14 @@ func TestBeforeMessageStored(t *testing.T) {
assert_eq(msg.mailboxes, {"one", "two"})
assert_eq(msg.from.name, "From Name")
assert_eq(msg.from.address, "from@example.com")
assert_eq(2, #msg.to)
assert_eq(2, #msg.to, "#msg.to")
assert_eq(msg.to[1].name, "To1 Name")
assert_eq(msg.to[1].address, "to1@example.com")
assert_eq(msg.to[2].name, "To2 Name")
assert_eq(msg.to[2].address, "to2@example.com")
assert_eq(msg.subject, "inbound subj")
assert_eq(msg.size, 42)
notify:send(test_ok)
assert_eq(msg.size, 42, "msg.size")
notify:send(asserts_ok)
-- Generate response.
res = inbound_message.new()
@@ -219,7 +194,8 @@ func TestBeforeMessageStored(t *testing.T) {
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
@@ -228,7 +204,7 @@ func TestBeforeMessageStored(t *testing.T) {
require.NotNil(t, got, "Expected result from Emit()")
// Verify Lua assertions passed.
assertNotified(t, notify)
test.AssertNotified(t, notify)
// Verify response values.
want := &event.InboundMessage{
@@ -244,15 +220,84 @@ func TestBeforeMessageStored(t *testing.T) {
assert.Equal(t, want, got, "Response InboundMessage did not match")
}
func assertNotified(t *testing.T, notify chan lua.LValue) {
t.Helper()
select {
case reslv := <-notify:
// Lua function received event.
if lua.LVIsFalse(reslv) {
t.Error("Lua responsed with false, wanted true")
}
case <-time.After(2 * time.Second):
t.Fatal("Lua did not respond to event within timeout")
func TestBeforeMessageStoredNilReturn(t *testing.T) {
// Event to send.
msg := event.InboundMessage{
Mailboxes: []string{"one", "two"},
From: &mail.Address{Name: "From Name", Address: "from@example.com"},
To: []*mail.Address{
{Name: "To1 Name", Address: "to1@example.com"},
{Name: "To2 Name", Address: "to2@example.com"},
},
Subject: "inbound subj",
Size: 42,
}
// Register lua event listener.
script := `
async = true
function inbucket.before.message_stored(msg)
assert(msg)
notify:send(asserts_ok)
-- Generate response.
return nil
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event to be accepted.
got := extHost.Events.BeforeMessageStored.Emit(&msg)
require.Nil(t, got, "Expected nil result from Emit()")
// Verify Lua assertions passed.
test.AssertNotified(t, notify)
}
func TestBeforeRcptToAccepted(t *testing.T) {
// Event to send.
session := event.SMTPSession{
From: &mail.Address{Name: "", Address: "from@example.com"},
To: []*mail.Address{
{Name: "", Address: "to1@example.com"},
{Name: "", Address: "to2@example.com"},
},
}
// Register lua event listener.
script := `
async = true
function inbucket.before.rcpt_to_accepted(msg)
-- Verify incoming values.
assert_eq(msg.from.address, "from@example.com")
assert_eq(2, #msg.to, "#msg.to")
assert_eq(msg.to[1].address, "to1@example.com")
assert_eq(msg.to[2].address, "to2@example.com")
notify:send(asserts_ok)
return smtp.allow()
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(test.LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event to be accepted.
got := extHost.Events.BeforeRcptToAccepted.Emit(&session)
require.NotNil(t, got, "Expected result from Emit()")
// Verify Lua assertions passed.
test.AssertNotified(t, notify)
// Verify response values.
want := event.SMTPResponse{Action: event.ActionAllow}
assert.Equal(t, want, *got)
}

View File

@@ -48,7 +48,8 @@ func (lp *statePool) newState() (*lua.LState, error) {
registerInbucketTypes(ls)
registerMailAddressType(ls)
registerMessageMetadataType(ls)
registerPolicyType(ls)
registerSMTPResponseType(ls)
registerSMTPSessionType(ls)
// Run compiled script.
ls.Push(ls.NewFunctionFromProto(lp.funcProto))

View File

@@ -47,7 +47,7 @@ func TestPoolGrowsWithPuts(t *testing.T) {
require.NoError(t, err)
b, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
assert.Empty(t, pool.states, "Wanted pool to be empty")
pool.putState(a)
pool.putState(b)
@@ -64,11 +64,11 @@ func TestPoolPutDiscardsClosed(t *testing.T) {
a, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
assert.Empty(t, pool.states, "Wanted pool to be empty")
a.Close()
pool.putState(a)
assert.Equal(t, 0, len(pool.states), "Wanted pool to remain empty")
assert.Empty(t, pool.states, "Wanted pool to remain empty")
}
func TestPoolPutClearsStack(t *testing.T) {
@@ -76,7 +76,7 @@ func TestPoolPutClearsStack(t *testing.T) {
ls, err := pool.getState()
require.NoError(t, err)
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
assert.Empty(t, pool.states, "Wanted pool to be empty")
// Setup stack.
ls.Push(lua.LNumber(4))
@@ -85,7 +85,7 @@ func TestPoolPutClearsStack(t *testing.T) {
// Return and verify stack cleared.
pool.putState(ls)
assert.Equal(t, 1, len(pool.states), "Wanted pool to have one item")
assert.Len(t, pool.states, 1, "Wanted pool to have one item")
require.Equal(t, 0, ls.GetTop(), "Want stack to be empty")
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
"github.com/rs/zerolog/log"
)
@@ -79,9 +79,9 @@ func (s *StoreManager) Deliver(
tstamp := now.UTC().Format(recvdTimeFmt)
// Process inbound message through extensions.
mailboxes := make([]string, len(recipients))
for i, recip := range recipients {
mailboxes[i] = recip.Mailbox
mailboxes := make([]string, 0, len(recipients))
for _, recip := range recipients {
mailboxes = append(mailboxes, recip.Mailbox)
}
// Construct InboundMessage event and process through extensions.
@@ -110,9 +110,9 @@ func (s *StoreManager) Deliver(
// Deliver to each mailbox.
for _, mb := range inbound.Mailboxes {
// Append recipient and timestamp to generated Recieved header.
// Append recipient and timestamp to generated Received header.
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", from.Address.Address)
// Deliver message.
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
delivery := &Delivery{
@@ -122,8 +122,9 @@ func (s *StoreManager) Deliver(
To: inbound.To,
Date: now,
Subject: inbound.Subject,
Size: inbound.Size,
},
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
Reader: io.MultiReader(strings.NewReader(returnPath), strings.NewReader(recvd), bytes.NewReader(source)),
}
id, err := s.Store.AddMessage(delivery)
if err != nil {

View File

@@ -312,7 +312,7 @@ func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
origin,
[]*policy.Recipient{recip},
"Received: xyz\n",
[]byte("From: from@example.com\n\ntest email"),
[]byte("From: from@example.com\nSubject: events\n\ntest email."),
); err != nil {
t.Fatal(err)
}
@@ -321,6 +321,17 @@ func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, got, "No event received, or it was nil")
assertMessageCount(t, sm, "to@example.com", 1)
// Verify event content.
assert.Equal(t, "to@example.com", got.Mailbox)
assert.Equal(t, "from@example.com", got.From.Address)
assert.WithinDuration(t, time.Now(), got.Date, 5*time.Second)
assert.Equal(t, "events", got.Subject, nil)
assert.Equal(t, int64(51), got.Size)
require.Len(t, got.To, 1)
assert.Equal(t, "to@example.com", got.To[0].Address)
}
func TestDeliverBeforeAndAfterMessageStoredEvents(t *testing.T) {
@@ -352,7 +363,7 @@ func TestDeliverBeforeAndAfterMessageStoredEvents(t *testing.T) {
t.Fatal(err)
}
// Confirm mailbox names overriden by Before were sent to After event. Order is
// Confirm mailbox names overridden by `Before` were sent to `After` event. Order is
// not guaranteed.
got1, err := listener()
require.NoError(t, err)
@@ -368,10 +379,10 @@ func TestGetMessage(t *testing.T) {
// Add a test message.
subject := "getMessage1"
id := addTestMessage(sm, "box1", subject)
id := addTestMessage(sm, "get-box", subject)
// Verify retrieval of the test message.
msg, err := sm.GetMessage("box1", id)
msg, err := sm.GetMessage("get-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.Equal(t, subject, msg.Subject)
@@ -383,19 +394,19 @@ func TestMarkSeen(t *testing.T) {
// Add a test message.
subject := "getMessage1"
id := addTestMessage(sm, "box1", subject)
id := addTestMessage(sm, "seen-box", subject)
// Verify test message unseen.
msg, err := sm.GetMessage("box1", id)
msg, err := sm.GetMessage("seen-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.False(t, msg.Seen, "msg should be unseen")
err = sm.MarkSeen("box1", id)
assert.NoError(t, err, "MarkSeen should succeed")
err = sm.MarkSeen("seen-box", id)
require.NoError(t, err, "MarkSeen should succeed")
// Verify test message seen.
msg, err = sm.GetMessage("box1", id)
msg, err = sm.GetMessage("seen-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.True(t, msg.Seen, "msg should have been seen")
@@ -405,17 +416,17 @@ func TestRemoveMessage(t *testing.T) {
sm, _ := testStoreManager()
// Add test messages.
id1 := addTestMessage(sm, "box1", "subject 1")
id2 := addTestMessage(sm, "box1", "subject 2")
id3 := addTestMessage(sm, "box1", "subject 3")
got, err := sm.GetMetadata("box1")
id1 := addTestMessage(sm, "rm-box", "subject 1")
id2 := addTestMessage(sm, "rm-box", "subject 2")
id3 := addTestMessage(sm, "rm-box", "subject 3")
got, err := sm.GetMetadata("rm-box")
require.NoError(t, err)
require.Len(t, got, 3)
// Delete message 2 and verify.
err = sm.RemoveMessage("box1", id2)
assert.NoError(t, err)
got, err = sm.GetMetadata("box1")
err = sm.RemoveMessage("rm-box", id2)
require.NoError(t, err)
got, err = sm.GetMetadata("rm-box")
require.NoError(t, err)
require.Len(t, got, 2, "Should be 2 messages remaining")
@@ -431,19 +442,19 @@ func TestPurgeMessages(t *testing.T) {
sm, _ := testStoreManager()
// Add test messages.
_ = addTestMessage(sm, "box1", "subject 1")
_ = addTestMessage(sm, "box1", "subject 2")
_ = addTestMessage(sm, "box1", "subject 3")
got, err := sm.GetMetadata("box1")
_ = addTestMessage(sm, "purge-box", "subject 1")
_ = addTestMessage(sm, "purge-box", "subject 2")
_ = addTestMessage(sm, "purge-box", "subject 3")
got, err := sm.GetMetadata("purge-box")
require.NoError(t, err)
require.Len(t, got, 3)
// Purge and verify.
err = sm.PurgeMessages("box1")
assert.NoError(t, err)
got, err = sm.GetMetadata("box1")
err = sm.PurgeMessages("purge-box")
require.NoError(t, err)
require.Len(t, got, 0, "Purge should remove all mailbox messages")
got, err = sm.GetMetadata("purge-box")
require.NoError(t, err)
assert.Empty(t, got, "Purge should remove all mailbox messages")
}
func TestSourceReader(t *testing.T) {
@@ -490,6 +501,38 @@ func TestMailboxForAddress(t *testing.T) {
assert.Equal(t, addr, got, "FullNaming mode should return a full address for mailbox")
}
func TestReturnPath(t *testing.T) {
sm, _ := testStoreManager()
recvdHeader := "Received: xyz\n"
msgSource := `From: from@example.com
To: u1@example.com
Subject: return path
test email`
// Deliver message.
origin, _ := sm.AddrPolicy.ParseOrigin("821from@example.com")
recipient, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
err := sm.Deliver(origin, []*policy.Recipient{recipient}, recvdHeader, []byte(msgSource))
require.NoError(t, err)
// Find message ID.
msgs, err := sm.GetMetadata("u1@example.com")
require.NoError(t, err, "Failed to read mailbox")
require.Len(t, msgs, 1, "Unexpected mailbox len")
id := msgs[0].ID
// Read back and verify source.
r, err := sm.SourceReader("u1@example.com", id)
require.NoError(t, err, "SourceReader must succeed")
gotBytes, err := io.ReadAll(r)
require.NoError(t, err, "Failed to read source")
got := string(gotBytes)
assert.Contains(t, got, "Return-Path: <821from@example.com>\r\n", "Source should contain return-path")
}
// Returns an empty StoreManager and extension Host pair, configured for testing.
func testStoreManager() (*message.StoreManager, *extension.Host) {
extHost := extension.NewHost()
@@ -543,7 +586,7 @@ func assertMessageCount(t *testing.T, sm *message.StoreManager, mailbox string,
t.Helper()
metas, err := sm.GetMetadata(mailbox)
assert.NoError(t, err, "StoreManager GetMetadata failed")
require.NoError(t, err, "StoreManager GetMetadata failed")
got := len(metas)
if got != count {

View File

@@ -9,7 +9,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
// Message holds both the metadata and content of a message.

View File

@@ -140,7 +140,7 @@ func (hub *Hub) RemoveListener(l Listener) {
// for unit tests.
func (hub *Hub) Sync() {
done := make(chan struct{})
hub.opChan <- func(h *Hub) {
hub.opChan <- func(_ *Hub) {
close(done)
}
<-done

View File

@@ -2,6 +2,7 @@ package msghub
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
@@ -51,7 +52,7 @@ func (l *testListener) Receive(msg event.MessageMetadata) error {
close(l.overflow)
}
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
return fmt.Errorf("Too many messages")
return errors.New("too many messages")
}
return nil
}
@@ -80,7 +81,7 @@ func TestHubZeroLen(t *testing.T) {
hub := New(0, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ {
for range 100 {
hub.Dispatch(m)
}
// Ensures Hub doesn't panic
@@ -92,7 +93,7 @@ func TestHubZeroListeners(t *testing.T) {
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ {
for range 100 {
hub.Dispatch(m)
}
// Ensures Hub doesn't panic
@@ -177,7 +178,7 @@ func TestHubHistoryReplay(t *testing.T) {
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
@@ -202,7 +203,7 @@ func TestHubHistoryReplay(t *testing.T) {
t.Fatal("Timeout:", l2)
}
for i := 0; i < len(msgs); i++ {
for i := range msgs {
got := l2.messages[i].Subject
want := msgs[i].Subject
if got != want {
@@ -221,7 +222,7 @@ func TestHubHistoryDelete(t *testing.T) {
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "hub",
ID: strconv.Itoa(i),
@@ -252,7 +253,7 @@ func TestHubHistoryDelete(t *testing.T) {
}
want := []string{"subj 0", "subj 2"}
for i := 0; i < len(want); i++ {
for i := range want {
got := l2.messages[i].Subject
if got != want[i] {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
@@ -270,7 +271,7 @@ func TestHubHistoryReplayWrap(t *testing.T) {
// Broadcast more messages than the hub can hold
msgs := make([]event.MessageMetadata, 20)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
@@ -295,7 +296,7 @@ func TestHubHistoryReplayWrap(t *testing.T) {
t.Fatal("Timeout:", l2)
}
for i := 0; i < 5; i++ {
for i := range 5 {
got := l2.messages[i].Subject
want := msgs[i+15].Subject
if got != want {
@@ -325,7 +326,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
// Broadcast more messages than the hub can hold.
msgs := make([]event.MessageMetadata, 10)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "first",
ID: strconv.Itoa(i),
@@ -342,7 +343,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
hub.Delete("first", "7")
// Broadcast another set of messages.
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "second",
ID: strconv.Itoa(i),

View File

@@ -2,6 +2,7 @@ package policy
import (
"bytes"
"errors"
"fmt"
"net"
"net/mail"
@@ -37,7 +38,7 @@ func (a *Addressing) ExtractMailbox(address string) (string, error) {
}
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 == "" {
@@ -45,7 +46,7 @@ func (a *Addressing) ExtractMailbox(address string) (string, error) {
}
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
@@ -74,6 +75,11 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
// not To headers.
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
if address == "" {
return &Origin{
addrPolicy: a,
}, nil
}
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
@@ -136,7 +142,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
return "", "", err
}
if !ValidateDomainPart(domain) {
return "", "", fmt.Errorf("Domain part validation failed")
return "", "", errors.New("domain part validation failed")
}
return local, domain, nil
}
@@ -229,7 +235,7 @@ func extractDomainMailbox(address string) (string, error) {
}
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 domain, nil
@@ -240,26 +246,26 @@ func extractDomainMailbox(address string) (string, error) {
// domain part is optional and not validated.
func parseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", fmt.Errorf("empty address")
return "", "", errors.New("empty address")
}
if len(address) > 320 {
return "", "", fmt.Errorf("address exceeds 320 characters")
return "", "", errors.New("address exceeds 320 characters")
}
// Remove forward-path routes.
if address[0] == '@' {
end := strings.IndexRune(address, ':')
if end == -1 {
return "", "", fmt.Errorf("missing terminating ':' in route specification")
return "", "", errors.New("missing terminating ':' in route specification")
}
address = address[end+1:]
if address == "" {
return "", "", fmt.Errorf("Address empty after removing route specification")
return "", "", errors.New("address empty after removing route specification")
}
}
if address[0] == '.' {
return "", "", fmt.Errorf("address cannot start with a period")
return "", "", errors.New("address cannot start with a period")
}
// Loop over address parsing out local part.
@@ -268,7 +274,7 @@ func parseEmailAddress(address string) (local string, domain string, err error)
inCharQuote := false
inStringQuote := false
LOOP:
for i := 0; i < len(address); i++ {
for i := range len(address) {
c := address[i]
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
@@ -285,7 +291,7 @@ LOOP:
return
}
inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
case strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0:
// These specials can be used unquoted.
err = buf.WriteByte(c)
if err != nil {
@@ -296,7 +302,7 @@ LOOP:
// A single period is OK.
if prev == '.' {
// Sequence of periods is not permitted.
return "", "", fmt.Errorf("Sequence of periods is not permitted")
return "", "", errors.New("sequence of periods is not permitted")
}
err = buf.WriteByte(c)
if err != nil {
@@ -306,19 +312,20 @@ LOOP:
case c == '\\':
inCharQuote = true
case c == '"':
if inCharQuote {
switch {
case inCharQuote:
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else if inStringQuote {
case inStringQuote:
inStringQuote = false
} else {
default:
if i == 0 {
inStringQuote = true
} else {
return "", "", fmt.Errorf("Quoted string can only begin at start of address")
return "", "", errors.New("quoted string can only begin at start of address")
}
}
case c == '@':
@@ -331,16 +338,16 @@ LOOP:
} else {
// End of local-part.
if i > 128 {
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
return "", "", errors.New("local part must not exceed 128 characters")
}
if prev == '.' {
return "", "", fmt.Errorf("Local part cannot end with a period")
return "", "", errors.New("local part cannot end with a period")
}
domain = address[i+1:]
break LOOP
}
case c > 127:
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
return "", "", errors.New("characters outside of US-ASCII range not permitted")
default:
if inCharQuote || inStringQuote {
err = buf.WriteByte(c)
@@ -349,16 +356,16 @@ LOOP:
}
inCharQuote = false
} else {
return "", "", fmt.Errorf("Character %q must be quoted", c)
return "", "", fmt.Errorf("character %q must be quoted", c)
}
}
prev = c
}
if inCharQuote {
return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair")
return "", "", errors.New("cannot end address with unterminated quoted-pair")
}
if inStringQuote {
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
return "", "", errors.New("cannot end address with unterminated string quote")
}
return buf.String(), domain, nil
}
@@ -369,22 +376,22 @@ LOOP:
// quoted according to RFC3696.
func parseMailboxName(localPart string) (result string, err error) {
if localPart == "" {
return "", fmt.Errorf("Mailbox name cannot be empty")
return "", errors.New("mailbox name cannot be empty")
}
result = strings.ToLower(localPart)
invalid := make([]byte, 0, 10)
for i := 0; i < len(result); i++ {
for i := range len(result) {
c := result[i]
switch {
case 'a' <= c && c <= 'z':
case '0' <= c && c <= '9':
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
case strings.IndexByte("!#$%&'*+-=/?^_`.{|}~", c) >= 0:
default:
invalid = append(invalid, c)
}
}
if len(invalid) > 0 {
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
return "", fmt.Errorf("mailbox name contained invalid character(s): %q", invalid)
}
if idx := strings.Index(result, "+"); idx > -1 {
result = result[0:idx]

View File

@@ -33,7 +33,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with default reject.
@@ -60,7 +59,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -90,7 +88,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with storage disabled.
@@ -117,7 +114,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -287,24 +283,18 @@ func TestExtractMailboxValid(t *testing.T) {
for _, tc := range testTable {
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with local naming %q: %v", tc.input, err)
} else {
if result != tc.local {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
}
} else if result != tc.local {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
}
if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with full naming %q: %v", tc.input, err)
} else {
if result != tc.full {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
}
} else if result != tc.full {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
}
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
} else {
if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
} else if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
}
}
@@ -331,10 +321,8 @@ func TestExtractDomainMailboxValid(t *testing.T) {
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)
}
} else if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
})
}

View File

@@ -26,7 +26,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
return fmt.Errorf("failed to get messages for %v: %v", name, err)
}
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
@@ -108,7 +108,7 @@ func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Contex
dec := json.NewDecoder(req.Body)
dm := model.JSONMessageHeaderV1{}
if err := dec.Decode(&dm); err != nil {
return fmt.Errorf("Failed to decode JSON: %v", err)
return fmt.Errorf("failed to decode JSON: %v", err)
}
if dm.Seen {
err = ctx.Manager.MarkSeen(name, id)

View File

@@ -12,7 +12,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
func TestRestMailboxList(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package client
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
@@ -41,33 +42,56 @@ func New(baseURL string, opts ...Option) (*Client, error) {
}
// ListMailbox returns a list of messages for the requested mailbox
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
func (c *Client) ListMailbox(name string) ([]*MessageHeader, error) {
return c.ListMailboxWithContext(context.Background(), name)
}
// ListMailboxWithContext returns a list of messages for the requested mailbox
func (c *Client) ListMailboxWithContext(ctx context.Context, name string) ([]*MessageHeader, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
err = c.doJSON("GET", uri, &headers)
headers := make([]*MessageHeader, 0, 32)
err := c.doJSON(ctx, "GET", uri, &headers)
if err != nil {
return nil, err
}
// Add Client ref to each MessageHeader for convenience funcs.
for _, h := range headers {
h.client = c
}
return
return headers, nil
}
// GetMessage returns the message details given a mailbox name and message ID.
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
return c.GetMessageWithContext(context.Background(), name, id)
}
// GetMessageWithContext returns the message details given a mailbox name and message ID.
func (c *Client) GetMessageWithContext(ctx context.Context, name, id string) (*Message, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err = c.doJSON("GET", uri, &message)
var message Message
err := c.doJSON(ctx, "GET", uri, &message)
if err != nil {
return nil, err
}
message.client = c
return
return &message, nil
}
// MarkSeen marks the specified message as having been read.
func (c *Client) MarkSeen(name, id string) error {
return c.MarkSeenWithContext(context.Background(), name, id)
}
// MarkSeenWithContext marks the specified message as having been read.
func (c *Client) MarkSeenWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err := c.doJSON("PATCH", uri, nil)
err := c.doJSON(ctx, "PATCH", uri, nil)
if err != nil {
return err
}
@@ -76,19 +100,25 @@ func (c *Client) MarkSeen(name, id string) error {
// GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
return c.GetMessageSourceWithContext(context.Background(), name, id)
}
// GetMessageSourceWithContext returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSourceWithContext(ctx context.Context, name, id string) (*bytes.Buffer, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
resp, err := c.do("GET", uri, nil)
resp, err := c.do(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil,
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
return buf, err
@@ -96,29 +126,43 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
return c.DeleteMessageWithContext(context.Background(), name, id)
}
// DeleteMessageWithContext deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessageWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error {
return c.PurgeMailboxWithContext(context.Background(), name)
}
// PurgeMailboxWithContext deletes all messages in the given mailbox
func (c *Client) PurgeMailboxWithContext(ctx context.Context, name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
@@ -130,17 +174,32 @@ type MessageHeader struct {
// GetMessage returns this message with content
func (h *MessageHeader) GetMessage() (message *Message, err error) {
return h.client.GetMessage(h.Mailbox, h.ID)
return h.GetMessageWithContext(context.Background())
}
// GetMessageWithContext returns this message with content
func (h *MessageHeader) GetMessageWithContext(ctx context.Context) (message *Message, err error) {
return h.client.GetMessageWithContext(ctx, h.Mailbox, h.ID)
}
// GetSource returns the source for this message
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
return h.client.GetMessageSource(h.Mailbox, h.ID)
return h.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (h *MessageHeader) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return h.client.GetMessageSourceWithContext(ctx, h.Mailbox, h.ID)
}
// Delete deletes this message from the mailbox
func (h *MessageHeader) Delete() error {
return h.client.DeleteMessage(h.Mailbox, h.ID)
return h.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (h *MessageHeader) DeleteWithContext(ctx context.Context) error {
return h.client.DeleteMessageWithContext(ctx, h.Mailbox, h.ID)
}
// Message represents an Inbucket message including content
@@ -151,10 +210,20 @@ type Message struct {
// GetSource returns the source for this message
func (m *Message) GetSource() (*bytes.Buffer, error) {
return m.client.GetMessageSource(m.Mailbox, m.ID)
return m.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (m *Message) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return m.client.GetMessageSourceWithContext(ctx, m.Mailbox, m.ID)
}
// Delete deletes this message from the mailbox
func (m *Message) Delete() error {
return m.client.DeleteMessage(m.Mailbox, m.ID)
return m.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (m *Message) DeleteWithContext(ctx context.Context) error {
return m.client.DeleteMessageWithContext(ctx, m.Mailbox, m.ID)
}

View File

@@ -13,7 +13,7 @@ type options struct {
// Option can apply itself to the private options type.
type Option interface {
apply(*options)
apply(opts *options)
}
func getDefaultOptions() *options {

View File

@@ -16,34 +16,42 @@ func Example() {
baseURL, teardown := exampleSetup()
defer teardown()
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
log.Fatal(err)
}
err := func() error {
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
return err
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
log.Fatal(err)
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
return err
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
log.Fatal(err)
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
return err
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Delete the second message.
err = headers[1].Delete()
if err != nil {
return err
}
return nil
}()
// Delete the second message.
err = headers[1].Delete()
if err != nil {
log.Fatal(err)
log.Print(err)
}
// Output:

View File

@@ -2,6 +2,7 @@ package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -11,7 +12,7 @@ import (
// httpClient allows http.Client to be mocked for tests
type httpClient interface {
Do(*http.Request) (*http.Response, error)
Do(req *http.Request) (*http.Response, error)
}
// Generic REST restClient
@@ -21,26 +22,24 @@ type restClient struct {
}
// do performs an HTTP request with this client and returns the response.
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
func (c *restClient) do(ctx context.Context, method, uri string, body []byte) (*http.Response, error) {
url := c.baseURL.JoinPath(uri)
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url.String(), r)
req, err := http.NewRequestWithContext(ctx, method, url.String(), r)
if err != nil {
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri, nil)
func (c *restClient) doJSON(ctx context.Context, method string, uri string, v interface{}) error {
resp, err := c.do(ctx, method, uri, nil)
if err != nil {
return err
}

View File

@@ -2,22 +2,33 @@ package client
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
const baseURLStr = "http://test.local:8080"
const baseURLPathStr = "http://test.local:8080/inbucket"
var baseURL *url.URL
var baseURLPath *url.URL
func init() {
var err error
baseURL, err = url.Parse(baseURLStr)
if err != nil {
panic(err)
}
baseURLPath, err = url.Parse(baseURLPathStr)
if err != nil {
panic(err)
}
}
type mockHTTPClient struct {
@@ -51,32 +62,42 @@ func (m *mockHTTPClient) ReqBody() []byte {
return body
}
func TestDo(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
body := []byte("Test body")
_, err := c.do("POST", "/dopost", body)
if err != nil {
t.Fatal(err)
func TestDoTable(t *testing.T) {
tests := []struct {
method string
uri string
wantMethod string
base *url.URL
wantURL string
wantBody []byte
}{
{method: "GET", wantMethod: "GET", uri: "/doget", base: baseURL, wantURL: baseURLStr + "/doget", wantBody: []byte("Test body 1")},
{method: "POST", wantMethod: "POST", uri: "/dopost", base: baseURL, wantURL: baseURLStr + "/dopost", wantBody: []byte("Test body 2")},
{method: "GET", wantMethod: "GET", uri: "/doget", base: baseURLPath, wantURL: baseURLPathStr + "/doget", wantBody: []byte("Test body 3")},
{method: "POST", wantMethod: "POST", uri: "/dopost", base: baseURLPath, wantURL: baseURLPathStr + "/dopost", wantBody: []byte("Test body 4")},
}
for _, test := range tests {
testname := fmt.Sprintf("%s,%s", test.method, test.wantURL)
t.Run(testname, func(t *testing.T) {
ctx := context.Background()
mth := &mockHTTPClient{}
c := &restClient{mth, test.base}
want = "POST"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
resp, err := c.do(ctx, test.method, test.uri, test.wantBody)
require.NoError(t, err)
err = resp.Body.Close()
require.NoError(t, err)
want = baseURLStr + "/dopost"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
b := mth.ReqBody()
if !bytes.Equal(b, body) {
t.Errorf("req.Body == %q, want %q", b, body)
if mth.req.Method != test.wantMethod {
t.Errorf("req.Method == %q, want %q", mth.req.Method, test.wantMethod)
}
if mth.req.URL.String() != test.wantURL {
t.Errorf("req.URL == %q, want %q", mth.req.URL.String(), test.wantURL)
}
if !bytes.Equal(mth.ReqBody(), test.wantBody) {
t.Errorf("req.Body == %q, want %q", mth.ReqBody(), test.wantBody)
}
})
}
}
@@ -89,7 +110,7 @@ func TestDoJSON(t *testing.T) {
c := &restClient{mth, baseURL}
var v map[string]interface{}
err := c.doJSON("GET", "/doget", &v)
err := c.doJSON(context.Background(), "GET", "/doget", &v)
if err != nil {
t.Fatal(err)
}
@@ -123,7 +144,7 @@ func TestDoJSONNilV(t *testing.T) {
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
err := c.doJSON("GET", "/doget", nil)
err := c.doJSON(context.Background(), "GET", "/doget", nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -1,7 +1,9 @@
package rest
import "github.com/gorilla/mux"
import "github.com/inbucket/inbucket/v3/pkg/server/web"
import (
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/server/web"
)
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {

View File

@@ -2,12 +2,14 @@ package rest
import (
"bytes"
"context"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
@@ -16,24 +18,36 @@ import (
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("GET", url, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(body))
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
@@ -122,9 +136,10 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if o == nil {
return nil, " is nil"
}
key := path[0]
present := false
var present bool
var val interface{}
key := path[0]
if key[0] == '[' {
// Expecting slice.
index, err := strconv.Atoi(strings.Trim(key, "[]"))
@@ -147,12 +162,15 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
}
val, present = omap[key]
}
if !present {
return nil, "/" + key + " is missing"
}
result, msg := getDecodedPath(val, path[1:]...)
if msg != "" {
return nil, "/" + key + msg
}
return result, ""
}

View File

@@ -37,7 +37,7 @@ func FullAssembly(conf *config.Root) (*Services, error) {
// Configure extensions.
extHost := extension.NewHost()
luaHost, err := luahost.New(conf.Lua, extHost)
if err != nil {
if err != nil && err != luahost.ErrNoScript {
return nil, err
}

View File

@@ -2,6 +2,7 @@ package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
@@ -101,7 +102,7 @@ func (s *Session) String() string {
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
func (s *Server) startSession(ctx context.Context, id int, conn net.Conn) {
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
@@ -134,48 +135,46 @@ func (s *Server) startSession(id int, conn net.Conn) {
line, err := ssn.readLine()
ssn.logger.Debug().Msgf("read %s", line)
if err == nil {
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
cmd, arg := ssn.parseCmd(line)
// Commands we handle in any state
if cmd == "CAPA" {
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
ssn.send(".")
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("-ERR Syntax error, command garbled")
ssn.send(".")
continue
}
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(ctx, cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
// readLine() returned an error
if err == io.EOF {
@@ -208,7 +207,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
// AUTHORIZATION state
func (s *Session) authorizationHandler(cmd string, args []string) {
func (s *Session) authorizationHandler(ctx context.Context, cmd string, args []string) {
switch cmd {
case "QUIT":
s.send("+OK Goodnight and good luck")
@@ -216,24 +215,26 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
s.enterState(QUIT)
case "STLS":
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
if !s.config.TLSEnabled || s.config.ForceTLS {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
s.send("-ERR TLS unavailable on the server")
s.ooSeq(cmd)
return
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("-ERR A TLS session already agreed upon.")
s.send("-ERR A TLS session already agreed upon.")
s.ooSeq(cmd)
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
tlsCtx, cancel := context.WithTimeout(ctx, s.config.Timeout)
defer cancel()
s.send("+OK Begin TLS Negotiation")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
tlsConn := tls.Server(s.conn, s.tlsConfig)
if err := tlsConn.HandshakeContext(tlsCtx); err != nil {
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
s.ooSeq(cmd)
}
@@ -463,7 +464,7 @@ func (s *Session) transactionHandler(cmd string, args []string) {
s.processDeletes()
s.enterState(QUIT)
case "NOOP":
s.send("+OK I have sucessfully done nothing")
s.send("+OK I have successfully done nothing")
case "RSET":
// Reset session, don't actually delete anything I told you to
s.logger.Debug().Msgf("Resetting session state on RSET request")
@@ -631,14 +632,14 @@ func (s *Session) readLine() (line string, err error) {
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
func (s *Session) parseCmd(line string) (cmd string, args []string) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", nil, true
return "", nil
}
words := strings.Split(line, " ")
return strings.ToUpper(words[0]), words[1:], true
return strings.ToUpper(words[0]), words[1:]
}
func (s *Session) reset() {

View File

@@ -29,8 +29,7 @@ func TestNoTLS(t *testing.T) {
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.PrintfLine("QUIT")
_, _ = c.ReadLine()
_ = c.Close()
server.Drain()
}()
@@ -41,21 +40,15 @@ func TestNoTLS(t *testing.T) {
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies := []string{}
for {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
}
replies = append(replies, reply)
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("TLS not enabled but received STLS.")
@@ -63,14 +56,44 @@ func TestNoTLS(t *testing.T) {
}
}
func TestSTLSWithTLSDisabled(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, false, false)
pipe := setupPOPSession(t, server)
_ = pipe.SetDeadline(time.Now().Add(10 * time.Second))
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Errorf("STLS should have errored: %s", reply)
}
}
func TestStartTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, false)
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.PrintfLine("QUIT")
_, _ = c.ReadLine()
_ = c.Close()
server.Drain()
}()
@@ -81,28 +104,21 @@ func TestStartTLS(t *testing.T) {
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies := []string{}
for {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
}
replies = append(replies, reply)
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
sawTLS := false
for _, r := range replies {
if r == "STLS" {
sawTLS = true
}
}
if !sawTLS {
t.Errorf("TLS enabled but no STLS capability.")
}
@@ -138,15 +154,95 @@ func TestStartTLS(t *testing.T) {
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("CAPA failed: %s", reply)
}
for {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
_, err = c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
}
func TestDupStartTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, false)
pipe := setupPOPSession(t, server)
_ = pipe.SetDeadline(time.Now().Add(10 * time.Second))
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response includes STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
sawTLS := false
for _, r := range replies {
if r == "STLS" {
sawTLS = true
}
}
if !sawTLS {
t.Errorf("TLS enabled but no STLS capability.")
}
t.Log("Sending first STLS command, expected to succeed")
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("STLS failed: %s", reply)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c = textproto.NewConn(tlsConn)
t.Log("Sending second STLS command, expected to fail")
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Fatalf("STLS failed: %s", reply)
}
// Send STAT to verify handler has not crashed.
if err := c.PrintfLine("STAT"); err != nil {
t.Fatalf("Failed to send STAT; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STAT reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Fatalf("STAT failed: %s", reply)
}
}
func TestForceTLS(t *testing.T) {
@@ -165,8 +261,7 @@ func TestForceTLS(t *testing.T) {
}
c := textproto.NewConn(tlsConn)
defer func() {
_ = c.PrintfLine("QUIT")
_, _ = c.ReadLine()
_ = c.Close()
server.Drain()
}()
@@ -178,27 +273,18 @@ func TestForceTLS(t *testing.T) {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
reply, err = c.ReadLine()
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA reply line failed %v", err)
t.Fatalf("Reading CAPA line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("CAPA failed: %s", reply)
}
for {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "STLS" {
for _, r := range replies {
if r == "STLS" {
t.Errorf("STLS in CAPA in forceTLS mode.")
}
if reply == "." {
break
}
}
}
@@ -216,7 +302,7 @@ func setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Se
cfg := config.POP3{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
Timeout: 5,
Timeout: 5 * time.Second,
Debug: true,
ForceTLS: forceTLS,
}
@@ -258,7 +344,7 @@ func setupPOPSession(t *testing.T, server *Server) net.Conn {
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
go server.startSession(context.Background(), sessionNum, &mockConn{serverConn})
return clientConn
}

View File

@@ -34,7 +34,7 @@ func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
return nil, fmt.Errorf("Failed to configure TLS; %v", err)
return nil, fmt.Errorf("failed to configure TLS; %v", err)
// Do not silently turn off Security.
}
slog.Debug().Msg("TLS config available")
@@ -97,8 +97,8 @@ func (s *Server) serve(ctx context.Context) {
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
if maxDelay := 1 * time.Second; tempDelay > maxDelay {
tempDelay = maxDelay
}
log.Error().Str("module", "pop3").Err(err).
Msgf("POP3 accept timout; retrying in %v", tempDelay)
@@ -120,7 +120,7 @@ func (s *Server) serve(ctx context.Context) {
} else {
tempDelay = 0
s.wg.Add(1)
go s.startSession(sid, conn)
go s.startSession(ctx, sid, conn)
}
}
}

View File

@@ -4,9 +4,11 @@ import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/mail"
"net/textproto"
"regexp"
"strconv"
@@ -114,7 +116,11 @@ type Session struct {
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
remoteHost := conn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteHost); err == nil {
remoteHost = host
}
session := &Session{
Server: server,
@@ -122,7 +128,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
conn: conn,
state: GREET,
reader: reader,
remoteHost: host,
remoteHost: remoteHost,
recipients: make([]*policy.Recipient, 0),
logger: logger,
debug: server.config.Debug,
@@ -139,20 +145,23 @@ func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
// Session flow:
// 1. Send initial greeting
// 2. Receive cmd
// 3. If good cmd, respond, optionally change state
// 4. If bad cmd, respond error
// 5. Goto 2
func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
logger = logger.Hook(logHook{}).With().
Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting SMTP session")
// Update WaitGroup and counters.
s.wg.Add(1)
expConnectsCurrent.Add(1)
expConnectsTotal.Add(1)
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
@@ -173,13 +182,13 @@ func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
}
line, err := ssn.readLine()
if err == nil {
//Handle LOGIN/PASSWORD states here, because they don't expect a command
// Handle LOGIN/PASSWORD states here, because they don't expect a command.
switch ssn.state {
case LOGIN:
ssn.loginHandler(line)
ssn.loginHandler()
continue
case PASSWORD:
ssn.passwordHandler(line)
ssn.passwordHandler()
continue
}
@@ -206,7 +215,7 @@ func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
ssn.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ssn.send("250 I have sucessfully done nothing")
ssn.send("250 I have successfully done nothing")
continue
case "RSET":
// Reset session
@@ -291,7 +300,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && !s.Server.config.ForceTLS && s.Server.tlsConfig != nil && s.tlsState == nil {
if s.config.TLSEnabled && !s.config.ForceTLS && s.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
@@ -307,18 +316,18 @@ func parseHelloArgument(arg string) (string, error) {
domain = arg[:idx]
}
if domain == "" {
return "", fmt.Errorf("Invalid domain")
return "", errors.New("invalid domain")
}
return domain, nil
}
func (s *Session) loginHandler(line string) {
func (s *Session) loginHandler() {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler(line string) {
func (s *Session) passwordHandler() {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
@@ -327,8 +336,9 @@ func (s *Session) passwordHandler(line string) {
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
switch cmd {
case "STARTTLS":
if !s.config.TLSEnabled {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
s.send("454 TLS unavailable on the server")
@@ -344,122 +354,130 @@ func (s *Session) readyHandler(cmd string, arg string) {
// Start TLS connection handshake.
s.send("220 STARTTLS")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
tlsConn := tls.Server(s.conn, s.tlsConfig)
s.conn = tlsConn
s.text = textproto.NewConn(s.conn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
} else if cmd == "AUTH" {
case "AUTH":
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
case "LOGIN":
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
default:
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
localpart, domain, err := policy.ParseEmailAddress(from)
s.logger.Debug().Msgf("Origin domain is %v", domain)
if from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
case "MAIL":
s.parseMailFromCmd(arg)
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
// Process through extensions.
extResult := s.extHost.Events.BeforeMailAccepted.Emit(
&event.AddressParts{Local: localpart, Domain: domain})
if extResult == nil || *extResult {
// Permitted by extension, or none had an opinion.
origin, err := s.addrPolicy.ParseOrigin(from)
if err != nil {
s.send("501 Bad origin address syntax")
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
return
}
s.from = origin
if !s.from.ShouldAccept() {
s.send("501 Unauthorized domain")
s.logger.Warn().Msgf("Bad domain sender %s", domain)
return
}
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" {
case "EHLO":
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
} else {
default:
s.ooSeq(cmd)
}
}
// Parses `MAIL FROM` command.
func (s *Session) parseMailFromCmd(arg string) {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
// Parse ESMTP parameters.
if m[2] != "" {
// Here the client may put BODY=8BITMIME, but Inbucket already
// reads the DATA as bytes, so it does not effect mail processing.
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
// Reject oversized messages.
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
// Parse origin (from) address.
origin, err := s.addrPolicy.ParseOrigin(from)
if err != nil {
s.send("501 Bad origin address syntax")
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
return
}
// Add from to extSession for inspection.
extSession := s.extSession()
addrCopy := origin.Address
extSession.From = &addrCopy
// Process through extensions.
extAction := event.ActionDefer
extResult := s.extHost.Events.BeforeMailFromAccepted.Emit(extSession)
if extResult != nil {
extAction = extResult.Action
}
if extAction == event.ActionDeny {
s.send(fmt.Sprintf("%03d %s", extResult.ErrorCode, extResult.ErrorMsg))
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
return
}
// Sender was permitted by an extension, or no extension rejected it.
s.from = origin
// Ignore ShouldAccept if extensions explicitly allowed this From.
if extAction == event.ActionDefer && !s.from.ShouldAccept() {
s.send("501 Unauthorized domain")
s.logger.Warn().Msgf("Bad domain sender %s", origin.Domain)
return
}
// Ok to transition to MAIL state.
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
}
// MAIL state -> waiting for RCPTs followed by DATA
func (s *Session) mailHandler(cmd string, arg string) {
switch cmd {
@@ -476,7 +494,26 @@ func (s *Session) mailHandler(cmd string, arg string) {
s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
return
}
if !recip.ShouldAccept() {
// Append new address to extSession for inspection.
addrCopy := recip.Address
extSession := s.extSession()
extSession.To = append(extSession.To, &addrCopy)
// Process through extensions.
extAction := event.ActionDefer
extResult := s.extHost.Events.BeforeRcptToAccepted.Emit(extSession)
if extResult != nil {
extAction = extResult.Action
}
if extAction == event.ActionDeny {
s.send(fmt.Sprintf("%03d %s", extResult.ErrorCode, extResult.ErrorMsg))
s.logger.Warn().Msgf("Extension denied mail to <%v>", recip.Address)
return
}
// Ignore ShouldAccept if extensions explicitly allowed this Recipient.
if extAction == event.ActionDefer && !recip.ShouldAccept() {
s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
s.send("550 Relay not permitted")
return
@@ -668,3 +705,23 @@ func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
}
// extSession builds an SMTPSession for extensions.
func (s *Session) extSession() *event.SMTPSession {
var from *mail.Address
if s.from != nil {
addr := s.from.Address
from = &addr
}
to := make([]*mail.Address, 0, len(s.recipients))
for _, recip := range s.recipients {
addr := recip.Address
to = append(to, &addr)
}
return &event.SMTPSession{
From: from,
To: to,
RemoteAddr: s.remoteHost,
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type scriptStep struct {
@@ -45,13 +46,10 @@ func TestGreetStateValidCommands(t *testing.T) {
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)
}
playSession(t, server, script)
})
}
}
@@ -60,7 +58,6 @@ func TestGreetStateValidCommands(t *testing.T) {
func TestGreetState(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
defer server.Drain() // Required to prevent test logging data race.
tests := []scriptStep{
{"HELO", 501},
@@ -73,46 +70,42 @@ func TestGreetState(t *testing.T) {
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)
}
playSession(t, server, script)
})
}
}
// Messages sent with a null reverse-path are unusual,
// but valid. They are used for delivery status
// notifications, and also for some sorts of auto-responder
// as part of bounce storm mitigation.
// Sections 3.6.3 and 4.5.5 of RFC 5321 discuss them.
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
defer server.Drain()
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
{"MAIL FROM:<>", 250},
}
playSession(t, server, script)
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
{"MAIL FROM: <>", 250},
}
playSession(t, server, script)
}
// Test AUTH commands.
func TestAuth(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
defer server.Drain()
// PLAIN AUTH
script := []scriptStep{
@@ -125,9 +118,7 @@ func TestAuth(t *testing.T) {
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// LOGIN AUTH
script = []scriptStep{
@@ -140,16 +131,13 @@ func TestAuth(t *testing.T) {
{"", 334},
{"", 235},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
}
// Test TLS commands.
func TestTLS(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
defer server.Drain()
// Test Start TLS parsing.
script := []scriptStep{
@@ -157,9 +145,7 @@ func TestTLS(t *testing.T) {
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
}
// Test valid commands in READY state.
@@ -186,14 +172,11 @@ func TestReadyStateValidCommands(t *testing.T) {
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)
}
playSession(t, server, script)
})
}
}
@@ -212,17 +195,13 @@ func TestReadyStateRejectedDomains(t *testing.T) {
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)
}
playSession(t, server, script)
})
}
}
// Test invalid commands in READY state.
@@ -245,24 +224,19 @@ func TestReadyStateInvalidCommands(t *testing.T) {
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)
}
playSession(t, server, script)
})
}
}
// Test commands in MAIL state
func TestMailState(t *testing.T) {
mds := test.NewStore()
server := setupSMTPServer(mds, extension.NewHost())
defer server.Drain()
// Test out some mangled READY commands
script := []scriptStep{
@@ -278,9 +252,7 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<first last@host.com>", 501},
{"RCPT TO:<fred@fish@host.com", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test out some good RCPT commands
script = []scriptStep{
@@ -297,9 +269,7 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@[127.0.0.1]>", 250},
{"RCPT TO:<u1@[IPv6:2001:db8:aaaa:1::100]>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test out recipient limit
script = []scriptStep{
@@ -312,9 +282,7 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u5@gmail.com>", 250},
{"RCPT TO:<u6@gmail.com>", 552},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test DATA
script = []scriptStep{
@@ -324,9 +292,7 @@ func TestMailState(t *testing.T) {
{"DATA", 354},
{".", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test late EHLO, similar to RSET
script = []scriptStep{
@@ -337,9 +303,7 @@ func TestMailState(t *testing.T) {
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test RSET
script = []scriptStep{
@@ -349,9 +313,7 @@ func TestMailState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test QUIT
script = []scriptStep{
@@ -360,16 +322,13 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"QUIT", 221},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
}
// Test commands in DATA state
func TestDataState(t *testing.T) {
mds := test.NewStore()
server := setupSMTPServer(mds, extension.NewHost())
defer server.Drain()
var script []scriptStep
pipe := setupSMTPSession(t, server)
@@ -384,9 +343,7 @@ func TestDataState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
playScriptAgainst(t, c, script)
// Send a message
body := `To: u1@gmail.com
@@ -416,9 +373,7 @@ Hi!
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
playScriptAgainst(t, c, script)
// Send a message
body = `X-Useless-Header: true
@@ -435,31 +390,282 @@ Hi!
_, _, _ = c.ReadCodeLine(221)
}
// Tests "MAIL FROM" emits BeforeMailFromAccepted event.
func TestBeforeMailFromAcceptedEventEmitted(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var got *event.SMTPSession
extHost.Events.BeforeMailFromAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
got = &session
return &event.SMTPResponse{Action: event.ActionDefer}
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"QUIT", 221}}
playSession(t, server, script)
assert.NotNil(t, got, "BeforeMailListener did not receive Address")
assert.Equal(t, "john@gmail.com", got.From.Address, "Address had wrong value")
assert.Equal(t, "pipe", got.RemoteAddr, "RemoteAddr had wrong value")
}
// Test "MAIL FROM" acts on BeforeMailFromAccepted event result.
func TestBeforeMailFromAcceptedEventResponse(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var shouldReturn *event.SMTPResponse
var gotEvent *event.SMTPSession
extHost.Events.BeforeMailFromAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
gotEvent = &session
return shouldReturn
})
tcs := map[string]struct {
script scriptStep // Command to send and SMTP code expected.
eventRes event.SMTPResponse // Response to send from event listener.
}{
"allow": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: event.SMTPResponse{Action: event.ActionAllow},
},
"deny": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 550},
eventRes: event.SMTPResponse{
Action: event.ActionDeny,
ErrorCode: 550,
ErrorMsg: "meh",
},
},
"defer": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: event.SMTPResponse{Action: event.ActionDefer},
},
}
for name, tc := range tcs {
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, // error code is the significant part.
{"QUIT", 221}}
playSession(t, server, script)
assert.NotNil(t, gotEvent, "BeforeMailFromAccepted did not receive event")
})
}
}
// Tests "RCPT TO" emits BeforeRcptToAccepted event.
func TestBeforeRcptToAcceptedSingleEventEmitted(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var got *event.SMTPSession
extHost.Events.BeforeRcptToAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
got = &session
return &event.SMTPResponse{Action: event.ActionDefer}
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user@gmail.com>", 250},
{"QUIT", 221}}
playSession(t, server, script)
require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession")
require.NotNil(t, got.From)
require.NotNil(t, got.To)
assert.Equal(t, "pipe", got.RemoteAddr, "RemoteAddr had wrong value")
assert.Equal(t, "john@gmail.com", got.From.Address)
assert.Len(t, got.To, 1)
assert.Equal(t, "user@gmail.com", got.To[0].Address)
}
// Tests "RCPT TO" emits many BeforeRcptToAccepted events.
func TestBeforeRcptToAcceptedManyEventsEmitted(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var called int
var got *event.SMTPSession
extHost.Events.BeforeRcptToAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
called++
got = &session
return &event.SMTPResponse{Action: event.ActionDefer}
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user@gmail.com>", 250},
{"RCPT TO:<user2@gmail.com>", 250},
{"QUIT", 221}}
playSession(t, server, script)
require.Equal(t, 2, called, "2 events should have been emitted")
require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession")
require.NotNil(t, got.From)
require.NotNil(t, got.To)
assert.Equal(t, "john@gmail.com", got.From.Address)
assert.Len(t, got.To, 2)
assert.Equal(t, "user@gmail.com", got.To[0].Address)
assert.Equal(t, "user2@gmail.com", got.To[1].Address)
}
// Tests we can continue after denying a "RCPT TO".
func TestBeforeRcptToAcceptedEventDeny(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var called int
var got *event.SMTPSession
extHost.Events.BeforeRcptToAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
called++
// Reject bad address.
action := event.ActionDefer
for _, to := range session.To {
if to.Address == "bad@apple.com" {
action = event.ActionDeny
}
}
got = &session
return &event.SMTPResponse{Action: action, ErrorCode: 550, ErrorMsg: "rotten"}
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user@gmail.com>", 250},
{"RCPT TO:<bad@apple.com>", 550},
{"RCPT TO:<user2@gmail.com>", 250},
{"QUIT", 221}}
playSession(t, server, script)
require.Equal(t, 3, called, "3 events should have been emitted")
require.NotNil(t, got, "BeforeRcptToListener did not receive SMTPSession")
require.NotNil(t, got.From)
require.NotNil(t, got.To)
assert.Equal(t, "john@gmail.com", got.From.Address)
// Verify bad apple dropped from final event.
assert.Len(t, got.To, 2)
assert.Equal(t, "user@gmail.com", got.To[0].Address)
assert.Equal(t, "user2@gmail.com", got.To[1].Address)
}
// Test "RCPT TO" acts on BeforeRcptToAccepted event result.
func TestBeforeRcptToAcceptedEventResponse(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var shouldReturn *event.SMTPResponse
var gotEvent *event.SMTPSession
extHost.Events.BeforeRcptToAccepted.AddListener(
"test",
func(session event.SMTPSession) *event.SMTPResponse {
gotEvent = &session
return shouldReturn
})
tcs := map[string]struct {
script scriptStep // Command to send and SMTP code expected.
eventRes event.SMTPResponse // Response to send from event listener.
}{
"allow": {
script: scriptStep{"RCPT TO:<john@gmail.com>", 250},
eventRes: event.SMTPResponse{Action: event.ActionAllow},
},
"deny": {
script: scriptStep{"RCPT TO:<john@gmail.com>", 550},
eventRes: event.SMTPResponse{
Action: event.ActionDeny,
ErrorCode: 550,
ErrorMsg: "meh",
},
},
"defer": {
script: scriptStep{"RCPT TO:<john@gmail.com>", 250},
eventRes: event.SMTPResponse{Action: event.ActionDefer},
},
}
for name, tc := range tcs {
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},
{"MAIL FROM:<user@gmail.com>", 250},
tc.script, // error code is the significant part.
{"QUIT", 221}}
playSession(t, server, script)
assert.NotNil(t, gotEvent, "BeforeRcptToListener did not receive SMTPSession")
})
}
}
// 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) {
t.Helper()
pipe := setupSMTPSession(t, server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
return fmt.Errorf("Expected a 220 greeting, got %v", code)
t.Errorf("expected a 220 greeting, got %v", code)
}
err := playScriptAgainst(t, c, script)
playScriptAgainst(t, c, script)
// Not all tests leave the session in a clean state, so the following two
// calls can fail
// Not all tests leave the session in a clean state, so the following two calls can fail
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
return err
}
// playScriptAgainst an existing connection, does not handle server greeting
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) error {
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) {
t.Helper()
for i, step := range script {
id, err := c.Cmd(step.send)
id, err := c.Cmd("%s", step.send)
if err != nil {
return fmt.Errorf("Step %d, failed to send %q: %v", i, step.send, err)
t.Fatalf("Step %d, failed to send %q: %v", i, step.send, err)
}
c.StartResponse(id)
@@ -471,98 +677,10 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
c.EndResponse(id)
if err != nil {
// Return after c.EndResponse so we don't hang the connection
return err
// Fail after c.EndResponse so we don't hang the connection
t.Fatal(err)
}
}
return nil
}
// 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
@@ -574,6 +692,7 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
// Creates an unstarted smtp.Server.
func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
cfg := &config.Root{
MailboxNaming: config.FullNaming,
@@ -589,7 +708,7 @@ func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
},
}
// Create a server, don't start it.
// Create a server, but don't start it.
addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds, ExtHost: extHost}
@@ -599,11 +718,18 @@ func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
var sessionNum int
func setupSMTPSession(t *testing.T, server *Server) net.Conn {
t.Helper()
logger := zerolog.New(zerolog.NewTestWriter(t))
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
_ = clientConn.Close()
// Drain is required to prevent a test-logging data race. If a (failing) test run is
// hanging, this may be the culprit.
server.Drain()
})
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn}, logger)

View File

@@ -154,8 +154,8 @@ func (s *Server) serve(ctx context.Context) {
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
if maxDelay := 1 * time.Second; tempDelay > maxDelay {
tempDelay = maxDelay
}
log.Error().Str("module", "smtp").Err(err).
Msgf("SMTP accept timeout; retrying in %v", tempDelay)
@@ -176,8 +176,6 @@ func (s *Server) serve(ctx context.Context) {
}
} else {
tempDelay = 0
expConnectsTotal.Add(1)
s.wg.Add(1)
go s.startSession(sessionID, conn, log.Logger)
}
}

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"os"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -86,13 +85,13 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
}
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
func spaTemplateHandler(tmpl *template.Template, basePath string,
webConfig config.Web) http.Handler {
func spaTemplateHandler(tmpl *template.Template, basePath string) http.Handler {
tmplData := struct {
BasePath string
}{
BasePath: basePath,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// ensure we do now allow click jacking
w.Header().Set("X-Frame-Options", "SameOrigin")

View File

@@ -21,6 +21,6 @@ func TextToHTML(text string) string {
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.Replace(url, "&amp;", "&", -1)
unescaped := strings.ReplaceAll(url, "&amp;", "&")
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}

View File

@@ -51,11 +51,7 @@ type Server struct {
}
// NewServer sets up things for unit tests or the Start() method.
func NewServer(
conf *config.Root,
mm message.Manager,
mh *msghub.Hub) *Server {
func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
rootConfig = conf
// NewContext() will use this DataStore for the web handlers.
@@ -113,7 +109,7 @@ func NewServer(
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
spaTemplateHandler(indexTmpl, prefix("/")))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
@@ -134,6 +130,11 @@ func NewServer(
// Start begins listening for HTTP requests
func (s *Server) Start(ctx context.Context, readyFunc func()) {
var (
err error
listenCfg net.ListenConfig
)
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: requestLoggingWrapper(Router),
@@ -144,8 +145,11 @@ func (s *Server) Start(ctx context.Context, readyFunc func()) {
// We don't use ListenAndServe because it lacks a way to close the listener
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
Msg("HTTP listening on tcp4")
var err error
listener, err = net.Listen("tcp", server.Addr)
// This context is only used while the listener is resolving our address.
listenCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
listener, err = listenCfg.Listen(listenCtx, "tcp", server.Addr)
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP failed to start TCP4 listener")

View File

@@ -2,6 +2,7 @@ package file
import (
"bufio"
"errors"
"fmt"
"io"
"os"
@@ -55,7 +56,7 @@ type Store struct {
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
path := cfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("'path' parameter not specified")
return nil, errors.New("'path' parameter not specified")
}
mailPath := getMailPath(path)

View File

@@ -2,20 +2,15 @@ package file
import (
"bytes"
"fmt"
"io"
"log"
"net/mail"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
@@ -38,7 +33,7 @@ func TestSuite(t *testing.T) {
func TestFSNew(t *testing.T) {
// Should fail if no path specified.
ds, err := New(config.Storage{}, extension.NewHost())
assert.ErrorContains(t, err, "parameter not specified")
require.ErrorContains(t, err, "parameter not specified")
assert.Nil(t, ds)
}
@@ -73,7 +68,7 @@ func TestFSDirStructure(t *testing.T) {
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
id1, _ := test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
@@ -90,7 +85,7 @@ func TestFSDirStructure(t *testing.T) {
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
@@ -100,7 +95,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err := ds.RemoveMessage(mbName, id1)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
@@ -110,7 +105,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err = ds.RemoveMessage(mbName, id2)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
@@ -141,21 +136,21 @@ func TestFSMissing(t *testing.T) {
for i, subj := range subjects {
// Add a message
id, _ := deliverMessage(ds, mbName, subj, time.Now())
id, _ := test.DeliverToStore(t, ds, mbName, subj, time.Now())
sentIds[i] = id
}
// Delete a message file without removing it from index
msg, err := ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
fmsg := msg.(*Message)
_ = os.Remove(fmsg.rawPath())
msg, err = ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
// Try to read parts of message
_, err = msg.Source()
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -176,29 +171,29 @@ func TestGetLatestMessage(t *testing.T) {
// Test empty mailbox
msg, err := ds.GetMessage(mbName, "latest")
assert.Nil(t, msg)
assert.Error(t, err)
require.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Test get the latest message
msg, err = ds.GetMessage(mbName, "latest")
require.NoError(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
assert.Equal(t, id2, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
id3, _ := test.DeliverToStore(t, ds, mbName, "test 3", time.Now())
msg, err = ds.GetMessage(mbName, "latest")
require.NoError(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
assert.Equal(t, id3, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = ds.GetMessage(mbName, "wrongid")
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -231,30 +226,6 @@ func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes
return s.(*Store), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
// Build message for delivery
meta := event.MessageMetadata{
Mailbox: mbName,
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: io.NopCloser(strings.NewReader(testMsg)),
}
id, err := ds.AddMessage(delivery)
if err != nil {
panic(err)
}
return id, int64(len(testMsg))
}
func teardownDataStore(ds *Store) {
if err := os.RemoveAll(ds.path); err != nil {
panic(err)

View File

@@ -112,6 +112,8 @@ func (mb *mbox) readIndex() error {
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
Msg("Index does not yet exist")
mb.indexLoaded = true
//lint:ignore nilerr missing mailboxes are considered empty.
return nil
}
file, err := os.Open(mb.indexPath)
@@ -130,7 +132,7 @@ func (mb *mbox) readIndex() error {
dec := gob.NewDecoder(br)
name := ""
if err = dec.Decode(&name); err != nil {
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
mb.name = name
for {
@@ -140,7 +142,7 @@ func (mb *mbox) readIndex() error {
if err == io.EOF {
break
}
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
msg.mailbox = mb
mb.messages = append(mb.messages, msg)

View File

@@ -9,7 +9,7 @@ import (
// access in most cases without requiring an infinite number of mutexes.
type HashLock [4096]sync.RWMutex
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexadecimal
// string of three or more characters.
func (h *HashLock) Get(hash string) *sync.RWMutex {
if len(hash) < 3 {

View File

@@ -36,7 +36,7 @@ func TestHashLock(t *testing.T) {
t.Run(ts, func(t *testing.T) {
l := hl.Get(ts)
if l == nil {
t.Errorf("Expeced non-nil lock for hex string %q", ts)
t.Errorf("Expected non-nil lock for hex string %q", ts)
}
})
}

View File

@@ -33,7 +33,7 @@ type mbox struct {
var _ storage.Store = &Store{}
// New returns an emtpy memory store.
// New returns an empty memory store.
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
s := &Store{
boxes: make(map[string]*mbox),
@@ -82,6 +82,7 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
m.id = id
m.source = source
mb.messages[id] = m
if s.cap > 0 {
// Enforce cap.
for len(mb.messages) > s.cap {

View File

@@ -37,7 +37,7 @@ func TestMaxSize(t *testing.T) {
for _, mailbox := range boxes {
go func(mailbox string) {
size := int64(0)
for i := 0; i < n; i++ {
for range n {
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
size += nbytes
}

View File

@@ -2,7 +2,7 @@ package stringutil
import (
"crypto/sha1"
"fmt"
"encoding/hex"
"io"
"net/mail"
"strings"
@@ -13,10 +13,11 @@ import (
func HashMailboxName(mailbox string) string {
h := sha1.New()
if _, err := io.WriteString(h, mailbox); err != nil {
// This shouldn't ever happen
// This should never happen.
return ""
}
return fmt.Sprintf("%x", h.Sum(nil))
return hex.EncodeToString(h.Sum(nil))
}
// StringAddress converts an Address to a UTF-8 string.
@@ -95,11 +96,9 @@ func MatchWithWildcards(p string, s string) bool {
if runePattern[j-1] == '*' {
isMatchingMatrix[0][j] = isMatchingMatrix[0][j-1]
}
}
for i := 1; i <= lenInput; i++ {
for j := 1; j <= lenPattern; j++ {
if runePattern[j-1] == '*' {
isMatchingMatrix[i][j] = isMatchingMatrix[i-1][j] || isMatchingMatrix[i][j-1]
}

View File

@@ -6,14 +6,17 @@ import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/stretchr/testify/assert"
)
func TestHashMailboxName(t *testing.T) {
want := "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
got := stringutil.HashMailboxName("mail")
if got != want {
t.Errorf("Got %q, want %q", got, want)
}
want := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
got := stringutil.HashMailboxName("")
assert.Equal(t, want, got, "for empty string")
want = "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
got = stringutil.HashMailboxName("mail")
assert.Equal(t, want, got, "for 'mail'")
}
func TestStringAddressList(t *testing.T) {

View File

@@ -27,6 +27,7 @@ import (
"github.com/jhillyerd/goldiff"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/suite"
)
const (
@@ -35,172 +36,128 @@ const (
)
// TODO: Add suites for domain and full addressing modes.
func TestSuite(t *testing.T) {
stopServer, err := startServer()
if err != nil {
t.Fatal(err)
}
defer stopServer()
testCases := []struct {
name string
test func(*testing.T)
}{
{"basic", testBasic},
{"fullname", testFullname},
{"encodedHeader", testEncodedHeader},
{"ipv4Recipient", testIPv4Recipient},
{"ipv6Recipient", testIPv6Recipient},
}
for _, tc := range testCases {
t.Run(tc.name, tc.test)
}
type IntegrationSuite struct {
suite.Suite
stopServer func()
}
func testBasic(t *testing.T) {
func (s *IntegrationSuite) SetupSuite() {
stopServer, err := startServer()
s.Require().NoError(err)
s.stopServer = stopServer
}
func (s *IntegrationSuite) TearDownSuite() {
s.stopServer()
}
func TestIntegrationSuite(t *testing.T) {
suite.Run(t, new(IntegrationSuite))
}
func (s *IntegrationSuite) TestBasic() {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("basic.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "basic.golden")
goldiff.File(s.T(), got, "testdata", "basic.golden")
}
func testFullname(t *testing.T) {
func (s *IntegrationSuite) TestFullname() {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("fullname.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "fullname.golden")
goldiff.File(s.T(), got, "testdata", "fullname.golden")
}
func testEncodedHeader(t *testing.T) {
func (s *IntegrationSuite) TestEncodedHeader() {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("encodedheader.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "encodedheader.golden")
goldiff.File(s.T(), got, "testdata", "encodedheader.golden")
}
func testIPv4Recipient(t *testing.T) {
func (s *IntegrationSuite) TestIPv4Recipient() {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"ip4recipient@[192.168.123.123]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(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.")
}
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "no-to-ipv4.golden")
goldiff.File(s.T(), got, "testdata", "no-to-ipv4.golden")
}
func testIPv6Recipient(t *testing.T) {
func (s *IntegrationSuite) TestIPv6Recipient() {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
s.Require().NoError(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.")
}
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "no-to-ipv6.golden")
goldiff.File(s.T(), got, "testdata", "no-to-ipv6.golden")
}
func formatMessage(m *client.Message) []byte {
@@ -282,8 +239,7 @@ func clearEnv() {
}
// Backup ciritcal env variables.
switch runtime.GOOS {
case "windows":
if runtime.GOOS == "windows" {
backup("SYSTEMROOT")
}

90
pkg/test/lua.go Normal file
View File

@@ -0,0 +1,90 @@
package test
import (
"strings"
"testing"
"time"
"github.com/cosmotek/loguago"
"github.com/rs/zerolog"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
local logger = require("logger")
async = false
asserts_ok = true
-- With async: marks tests as failed via asserts_ok, logs error.
-- Without async: erroring when tests fail.
function assert_async(value, message, label)
if not value then
if label then
message = string.format("%s for %s", message, label)
end
if async then
logger.error(message, {from = "assert_async"})
asserts_ok = false
else
error(message)
end
end
end
-- Verifies plain values and list-style tables.
function assert_eq(got, want, label)
if type(got) == "table" and type(want) == "table" then
assert_async(#got == #want,
string.format("got %d elements, wanted %d", #got, #want), label)
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv,
string.format("got[%d] = %q, wanted %q", gotv, wantv), label)
end
return
end
assert_async(got == want, string.format("got %q, wanted %q", got, want), label)
end
-- Verifies string want contains string got.
function assert_contains(got, want, label)
assert_async(string.find(got, want),
string.format("got %q, wanted it to contain %q", got, want), label)
end
`
// NewLuaState creates a new Lua LState initialized with logging and the test helpers in `LuaInit`.
//
// Returns a pointer to the created LState and a string builder to hold the log output.
func NewLuaState() (*lua.LState, *strings.Builder) {
output := &strings.Builder{}
logger := loguago.NewLogger(zerolog.New(output))
ls := lua.NewState()
ls.PreloadModule("logger", logger.Loader)
if err := ls.DoString(LuaInit); err != nil {
panic(err)
}
return ls, output
}
// AssertNotified requires a truthy LValue on the notify channel.
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")
}
}

View File

@@ -70,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
}
for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id {
msg.MessageMetadata.Seen = true
msg.Seen = true
return nil
}
}

View File

@@ -0,0 +1,61 @@
package test
import (
"fmt"
"io"
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
// generated message.
func DeliverToStore(
t *testing.T,
store storage.Store,
mailbox string,
subject string,
date time.Time,
) (string, int64) {
t.Helper()
meta := event.MessageMetadata{
Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: io.NopCloser(strings.NewReader(testMsg)),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
return id, int64(len(testMsg))
}
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
// also checks return error.
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
t.Helper()
msgs, err := s.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) != count {
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
}
return msgs
}

View File

@@ -22,11 +22,19 @@ import (
type StoreFactory func(
config.Storage, *extension.Host) (store storage.Store, destroy func(), err error)
// storeSuite is passed to each test function; embeds `testing.T` to provide testing primitives.
type storeSuite struct {
*testing.T
store storage.Store
extHost *extension.Host
}
// StoreSuite runs a set of general tests on the provided Store.
func StoreSuite(t *testing.T, factory StoreFactory) {
t.Helper()
testCases := []struct {
name string
test func(*testing.T, storage.Store, *extension.Host)
test func(storeSuite)
conf config.Storage
}{
{"metadata", testMetadata, config.Storage{}},
@@ -49,14 +57,20 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
if err != nil {
t.Fatal(err)
}
tc.test(t, store, extHost)
destroy()
defer destroy()
s := storeSuite{
T: t,
store: store,
extHost: extHost,
}
tc.test(s)
})
}
}
// testMetadata verifies message metadata is stored and retrieved correctly.
func testMetadata(t *testing.T, store storage.Store, extHost *extension.Host) {
func testMetadata(s storeSuite) {
mailbox := "testmailbox"
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
to := []*mail.Address{
@@ -78,54 +92,54 @@ func testMetadata(t *testing.T, store storage.Store, extHost *extension.Host) {
},
Reader: strings.NewReader(content),
}
id, err := store.AddMessage(delivery)
id, err := s.store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if id == "" {
t.Fatal("Expected AddMessage() to return non-empty ID string")
s.Fatal("Expected AddMessage() to return non-empty ID string")
}
// Retrieve and validate the message.
sm, err := store.GetMessage(mailbox, id)
sm, err := s.store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if sm.Mailbox() != mailbox {
t.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
s.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
}
if sm.ID() != id {
t.Errorf("got id %q, want: %q", sm.ID(), id)
s.Errorf("got id %q, want: %q", sm.ID(), id)
}
if *sm.From() != *from {
t.Errorf("got from %v, want: %v", sm.From(), from)
s.Errorf("got from %v, want: %v", sm.From(), from)
}
if len(sm.To()) != len(to) {
t.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
s.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
} else {
for i, got := range sm.To() {
if *to[i] != *got {
t.Errorf("got to[%v] %v, want: %v", i, got, to[i])
s.Errorf("got to[%v] %v, want: %v", i, got, to[i])
}
}
}
if !sm.Date().Equal(date) {
t.Errorf("got date %v, want: %v", sm.Date(), date)
s.Errorf("got date %v, want: %v", sm.Date(), date)
}
if sm.Subject() != subject {
t.Errorf("got subject %q, want: %q", sm.Subject(), subject)
s.Errorf("got subject %q, want: %q", sm.Subject(), subject)
}
if sm.Size() != int64(len(content)) {
t.Errorf("got size %v, want: %v", sm.Size(), len(content))
s.Errorf("got size %v, want: %v", sm.Size(), len(content))
}
if sm.Seen() {
t.Errorf("got seen %v, want: false", sm.Seen())
s.Errorf("got seen %v, want: false", sm.Seen())
}
}
// testContent generates some binary content and makes sure it is correctly retrieved.
func testContent(t *testing.T, store storage.Store, extHost *extension.Host) {
func testContent(s storeSuite) {
content := make([]byte, 5000)
for i := 0; i < len(content); i++ {
for i := range content {
content[i] = byte(i % 256)
}
mailbox := "testmailbox"
@@ -146,332 +160,287 @@ func testContent(t *testing.T, store storage.Store, extHost *extension.Host) {
},
Reader: bytes.NewReader(content),
}
id, err := store.AddMessage(delivery)
require.NoError(t, err, "AddMessage() failed")
id, err := s.store.AddMessage(delivery)
require.NoError(s, err, "AddMessage() failed")
// Read stored message source.
m, err := store.GetMessage(mailbox, id)
require.NoError(t, err, "GetMessage() failed")
m, err := s.store.GetMessage(mailbox, id)
require.NoError(s, err, "GetMessage() failed")
r, err := m.Source()
require.NoError(t, err, "Source() failed")
require.NoError(s, err, "Source() failed")
got, err := io.ReadAll(r)
require.NoError(t, err, "failed to read source")
require.NoError(s, err, "failed to read source")
err = r.Close()
assert.NoError(t, err, "failed to close source reader")
require.NoError(s, err, "failed to close source reader")
// Verify source.
if len(got) != len(content) {
t.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
s.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
}
errors := 0
for i, b := range got {
if b != content[i] {
t.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
s.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
errors++
}
if errors > 5 {
t.Fatalf("Too many content errors, aborting test.")
s.Fatalf("Too many content errors, aborting test.")
}
}
}
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
// with a new GetMessages call each cycle.
func testDeliveryOrder(t *testing.T, store storage.Store, extHost *extension.Host) {
func testDeliveryOrder(s storeSuite) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects {
// Check mailbox count.
GetAndCountMessages(t, store, mailbox, i)
DeliverToStore(t, store, mailbox, subj, time.Now())
GetAndCountMessages(s.T, s.store, mailbox, i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
}
// Confirm delivery order.
msgs := GetAndCountMessages(t, store, mailbox, 5)
msgs := GetAndCountMessages(s.T, s.store, mailbox, 5)
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
s.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
// the last message sent.
func testLatest(t *testing.T, store storage.Store, extHost *extension.Host) {
func testLatest(s storeSuite) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
}
// Confirm latest.
latest, err := store.GetMessage(mailbox, "latest")
latest, err := s.store.GetMessage(mailbox, "latest")
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if latest == nil {
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
s.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
}
got := latest.Subject()
want := "echo"
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
s.Errorf("Got subject %q, want %q", got, want)
}
}
// testNaming ensures the store does not enforce local part mailbox naming.
func testNaming(t *testing.T, store storage.Store, extHost *extension.Host) {
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(t, store, "fred", 0)
GetAndCountMessages(t, store, "fred@fish.net", 1)
func testNaming(s storeSuite) {
DeliverToStore(s.T, s.store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(s.T, s.store, "fred", 0)
GetAndCountMessages(s.T, s.store, "fred@fish.net", 1)
}
// testSize verifies message content size metadata values.
func testSize(t *testing.T, store storage.Store, extHost *extension.Host) {
func testSize(s storeSuite) {
mailbox := "fred"
subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects))
sentSizes := make([]int64, len(subjects))
for i, subj := range subjects {
id, size := DeliverToStore(t, store, mailbox, subj, time.Now())
id, size := DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
sentIds[i] = id
sentSizes[i] = size
}
for i, id := range sentIds {
msg, err := store.GetMessage(mailbox, id)
msg, err := s.store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
want := sentSizes[i]
got := msg.Size()
if got != want {
t.Errorf("Got size %v, want: %v", got, want)
s.Errorf("Got size %v, want: %v", got, want)
}
}
}
// testSeen verifies a message can be marked as seen.
func testSeen(t *testing.T, store storage.Store, extHost *extension.Host) {
func testSeen(s storeSuite) {
mailbox := "lisa"
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
id1, _ := DeliverToStore(s.T, s.store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(s.T, s.store, mailbox, "hello?", time.Now())
// Confirm unseen.
msg, err := store.GetMessage(mailbox, id1)
msg, err := s.store.GetMessage(mailbox, id1)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if msg.Seen() {
t.Errorf("got seen %v, want: false", msg.Seen())
s.Errorf("got seen %v, want: false", msg.Seen())
}
// Mark id1 seen.
err = store.MarkSeen(mailbox, id1)
err = s.store.MarkSeen(mailbox, id1)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
// Verify id1 seen.
msg, err = store.GetMessage(mailbox, id1)
msg, err = s.store.GetMessage(mailbox, id1)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if !msg.Seen() {
t.Errorf("id1 got seen %v, want: true", msg.Seen())
s.Errorf("id1 got seen %v, want: true", msg.Seen())
}
// Verify id2 still unseen.
msg, err = store.GetMessage(mailbox, id2)
msg, err = s.store.GetMessage(mailbox, id2)
if err != nil {
t.Fatal(err)
s.Fatal(err)
}
if msg.Seen() {
t.Errorf("id2 got seen %v, want: false", msg.Seen())
s.Errorf("id2 got seen %v, want: false", msg.Seen())
}
}
// testDelete creates and deletes some messages.
func testDelete(t *testing.T, store storage.Store, extHost *extension.Host) {
func testDelete(s storeSuite) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
}
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
msgs := GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
// Subscribe to events.
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
eventListener := s.extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
// Delete a couple messages.
deleteIDs := []string{msgs[1].ID(), msgs[3].ID()}
for _, id := range deleteIDs {
err := store.RemoveMessage(mailbox, id)
require.NoError(t, err)
err := s.store.RemoveMessage(mailbox, id)
require.NoError(s, err)
}
// Confirm deletion.
subjects = []string{"alpha", "charlie", "echo"}
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
s.Errorf("Got subject %q, want %q", got, want)
}
}
// Capture events and check correct IDs were emitted.
ev1, err := eventListener()
require.NoError(t, err)
require.NoError(s, err)
ev2, err := eventListener()
require.NoError(t, err)
require.NoError(s, err)
eventIDs := []string{ev1.ID, ev2.ID}
for _, id := range deleteIDs {
assert.Contains(t, eventIDs, id)
assert.Contains(s, eventIDs, id)
}
// Try appending one more.
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
DeliverToStore(s.T, s.store, mailbox, "foxtrot", time.Now())
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
s.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testPurge makes sure mailboxes can be purged.
func testPurge(t *testing.T, store storage.Store, extHost *extension.Host) {
func testPurge(s storeSuite) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
// Subscribe to events.
eventListener := extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
eventListener := s.extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
// Populate mailbox.
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
}
GetAndCountMessages(t, store, mailbox, len(subjects))
GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
// Purge and verify.
err := store.PurgeMessages(mailbox)
require.NoError(t, err)
GetAndCountMessages(t, store, mailbox, 0)
err := s.store.PurgeMessages(mailbox)
require.NoError(s, err)
GetAndCountMessages(s.T, s.store, mailbox, 0)
// Confirm events emitted.
gotEvents := []*event.MessageMetadata{}
for range subjects {
ev, err := eventListener()
if err != nil {
t.Error(err)
s.Error(err)
break
}
gotEvents = append(gotEvents, ev)
}
assert.Equal(t, len(subjects), len(gotEvents),
assert.Len(s, gotEvents, len(subjects),
"expected delete event for each message in mailbox")
}
// testMsgCap verifies the message cap is enforced.
func testMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
func testMsgCap(s storeSuite) {
mbCap := 10
mailbox := "captain"
for i := 0; i < 20; i++ {
for i := range 20 {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(t, store, mailbox, subj, time.Now())
msgs, err := store.GetMessages(mailbox)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
msgs, err := s.store.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
s.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) > mbCap {
t.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
s.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
break
}
// Check that the first message is correct.
first := i - mbCap + 1
if first < 0 {
first = 0
}
// Check that the first (oldest) message is correct.
first := max(i-mbCap+1, 0)
firstSubj := fmt.Sprintf("subject %v", first)
if firstSubj != msgs[0].Subject() {
t.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
}
}
}
// testNoMsgCap verfies a cap of 0 is not enforced.
func testNoMsgCap(t *testing.T, store storage.Store, extHost *extension.Host) {
func testNoMsgCap(s storeSuite) {
mailbox := "captain"
for i := 0; i < 20; i++ {
for i := range 20 {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(t, store, mailbox, subj, time.Now())
GetAndCountMessages(t, store, mailbox, i+1)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
GetAndCountMessages(s.T, s.store, mailbox, i+1)
}
}
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
// them.
func testVisitMailboxes(t *testing.T, ds storage.Store, extHost *extension.Host) {
func testVisitMailboxes(s storeSuite) {
// Deliver 2 test messages to each of 5 mailboxes.
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
for _, name := range boxes {
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
DeliverToStore(t, ds, name, "New Message", time.Now())
DeliverToStore(s.T, s.store, name, "Old Message", time.Now().Add(-24*time.Hour))
DeliverToStore(s.T, s.store, name, "New Message", time.Now())
}
// Verify message and mailbox counts.
nboxes := 0
err := ds.VisitMailboxes(func(messages []storage.Message) bool {
err := s.store.VisitMailboxes(func(messages []storage.Message) bool {
nboxes++
name := "unknown"
if len(messages) > 0 {
name = messages[0].Mailbox()
}
assert.Equal(t, 2, len(messages), "incorrect message count in mailbox %s", name)
assert.Len(s, messages, 2, "incorrect message count in mailbox %s", name)
return true
})
assert.NoError(t, err, "VisitMailboxes() failed")
assert.Equal(t, 5, nboxes, "visited %v mailboxes, want: 5", nboxes)
}
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
// generated message.
func DeliverToStore(
t *testing.T,
store storage.Store,
mailbox string,
subject string,
date time.Time,
) (string, int64) {
t.Helper()
meta := event.MessageMetadata{
Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: io.NopCloser(strings.NewReader(testMsg)),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
return id, int64(len(testMsg))
}
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
// also checks return error.
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
t.Helper()
msgs, err := s.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) != count {
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
}
return msgs
require.NoError(s, err, "VisitMailboxes() failed")
assert.Equal(s, 5, nboxes, "visited %v mailboxes, want: 5", nboxes)
}

View File

@@ -31,7 +31,6 @@ func TestStoreStubMailboxAddGetVisit(t *testing.T) {
{mailbox: "box3", count: 3},
}
for _, tc := range tcs {
tc := tc
t.Run(tc.mailbox, func(t *testing.T) {
var err error
@@ -84,7 +83,7 @@ func TestStoreStubMailboxAddGetVisit(t *testing.T) {
want, ok := expectCounts[mailbox]
assert.True(t, ok, "Mailbox %q was unexpected", mailbox)
assert.Equal(t, want, len(m), "Unexpected message count for mailbox %q", mailbox)
assert.Len(t, m, want, "Unexpected message count for mailbox %q", mailbox)
delete(expectCounts, mailbox)
@@ -114,7 +113,7 @@ func TestStoreStubMarkSeen(t *testing.T) {
// Mark second message as seen.
seen := inputMsgs[1]
err := ss.MarkSeen("box1", seen.ID())
assert.NoError(t, err, "MarkSeen must not fail")
require.NoError(t, err, "MarkSeen must not fail")
// Verify message has seen flag.
got, err := ss.GetMessage("box1", seen.ID())
@@ -150,16 +149,16 @@ func TestStoreStubRemoveMessage(t *testing.T) {
// Delete second message.
deleted := inputMsgs[1]
err := ss.RemoveMessage("box1", deleted.ID())
assert.NoError(t, err, "DeleteMessage must not fail")
require.NoError(t, err, "DeleteMessage must not fail")
// Verify message is not in mailbox.
messages, err := ss.GetMessages("box1")
assert.NoError(t, err)
require.NoError(t, err)
assert.NotContains(t, messages, deleted, "Mailbox should not contain msg %q", deleted.ID())
// Verify message is no longer retrievable.
got, err := ss.GetMessage("box1", deleted.ID())
assert.Error(t, err)
require.Error(t, err)
assert.Nil(t, got, "Message should have been nil")
// Verify message is in deleted list.
@@ -181,12 +180,12 @@ func TestStoreStubPurgeMessages(t *testing.T) {
// Purge messages.
err := ss.PurgeMessages("box1")
assert.NoError(t, err, "PurgeMessages must not fail")
require.NoError(t, err, "PurgeMessages must not fail")
// Verify message is not in mailbox.
messages, err := ss.GetMessages("box1")
assert.NoError(t, err)
assert.Len(t, messages, 0, "Mailbox should be empty")
require.NoError(t, err)
assert.Empty(t, messages, "Mailbox should be empty")
// Verify messages are in deleted list.
for _, want := range inputMsgs {
@@ -208,15 +207,15 @@ func TestStoreStubForcedErrors(t *testing.T) {
// Verify methods return error.
_, err = ss.GetMessage("messageerr", id1)
assert.Error(t, err, "GetMessage()")
require.Error(t, err, "GetMessage()")
assert.NotEqual(t, storage.ErrNotExist, err)
_, err = ss.GetMessages("messageserr")
assert.Error(t, err, "GetMessages()")
require.Error(t, err, "GetMessages()")
assert.NotEqual(t, storage.ErrNotExist, err)
err = ss.MarkSeen("messageerr", id1)
assert.Error(t, err, "MarkSeen()")
require.Error(t, err, "MarkSeen()")
assert.NotEqual(t, storage.ErrNotExist, err)
}

View File

@@ -2,7 +2,7 @@ Mailbox: recipient
From: <fromuser@inbucket.org>
To: [<recipient@inbucket.org>]
Subject: basic subject
Size: 204
Size: 242
BODY TEXT:
Basic message.

View File

@@ -2,7 +2,7 @@ Mailbox: recipient
From: X-äéß Y-äéß <fromuser@inbucket.org>
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
Subject: Test of ȇɲʢȯȡɪɴʛ
Size: 338
Size: 376
BODY TEXT:
Basic message.

View File

@@ -2,7 +2,7 @@ Mailbox: recipient
From: From User <fromuser@inbucket.org>
To: [Rec I. Pient <recipient@inbucket.org>]
Subject: basic subject
Size: 233
Size: 271
BODY TEXT:
Basic message.

View File

@@ -2,7 +2,7 @@ Mailbox: ip4recipient
From: <fromuser@inbucket.org>
To: [<ip4recipient@[192.168.123.123]>]
Subject: basic subject
Size: 180
Size: 218
BODY TEXT:
No-To message.

View File

@@ -2,7 +2,7 @@ Mailbox: ip6recipient
From: <fromuser@inbucket.org>
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
Subject: basic subject
Size: 180
Size: 218
BODY TEXT:
No-To message.

View File

@@ -13,7 +13,7 @@ import (
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
greeting, err := os.ReadFile(ctx.RootConfig.Web.GreetingFile)
if err != nil {
return fmt.Errorf("Failed to load greeting: %v", err)
return fmt.Errorf("failed to load greeting: %v", err)
}
w.Header().Set("Content-Type", "text/html")

View File

@@ -30,5 +30,4 @@ func TestSanitizeStyle(t *testing.T) {
}
})
}
}

View File

@@ -39,7 +39,7 @@ func sanitizeStyleTags(input string) (string, error) {
func styleTagFilter(w io.Writer, r io.Reader) error {
bw := bufio.NewWriter(w)
b := make([]byte, 256)
b := make([]byte, 0, 256)
z := html.NewTokenizer(r)
for {
b = b[:0]

View File

@@ -1,4 +1,6 @@
{ pkgs ? import <nixpkgs> { } }:
{
pkgs ? import <nixpkgs> { },
}:
let
scripts = {
# Quick test script.
@@ -22,7 +24,8 @@ pkgs.mkShell {
elmPackages.elm-json
elmPackages.elm-language-server
elmPackages.elm-test
go_1_21
go_1_25
golangci-lint
golint
gopls
nodejs_20

View File

@@ -17,7 +17,7 @@
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"jweir/sparkline": "4.0.0",
"ryannhg/date-format": "2.3.0"
"ryan-haskell/date-format": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",

View File

@@ -1016,9 +1016,9 @@ balanced-match@^1.0.0:
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base-x@^3.0.8:
version "3.0.9"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320"
integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==
version "3.0.11"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.11.tgz#40d80e2a1aeacba29792ccc6c5354806421287ff"
integrity sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==
dependencies:
safe-buffer "^5.0.1"
@@ -1035,12 +1035,12 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.0.1"
fill-range "^7.1.1"
browserslist@^4.6.6:
version "4.22.3"
@@ -1340,10 +1340,10 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
@@ -1578,9 +1578,9 @@ js-tokens@^4.0.0:
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies:
argparse "^2.0.1"
@@ -1697,11 +1697,11 @@ mdn-data@2.0.14:
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.2"
braces "^3.0.3"
picomatch "^2.3.1"
minimatch@^3.1.1: