1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-18 01:57:02 +00:00

Compare commits

..

496 Commits

Author SHA1 Message Date
James Hillyerd
997c6307b8 chore: Update Go version to 1.25 in release workflow 2025-11-22 11:46:30 -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
James Hillyerd
97506b2d2b update changelog for 3.1.0-beta2 (#471)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-05 12:46:42 -08:00
James Hillyerd
8667c70257 yarn/js: flexible version specs (#470)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-05 10:46:03 -08:00
James Hillyerd
a27b57df67 yarn/js: update parcel & deps (#469)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-04 21:53:34 -08:00
James Hillyerd
d8746e8093 node: 20.x (#468)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-04 21:25:43 -08:00
James Hillyerd
599200d32e go: update dependencies (#467)
* go: update dependencies
* go: minimum 1.21 required by deps

Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-04 20:50:49 -08:00
James Hillyerd
e190adef4d go: 1.21 (#466)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-04 20:03:30 -08:00
James Hillyerd
6a389c78cc rest/client: fix comment lint error (#465)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-04 19:50:02 -08:00
James Hillyerd
6d66012a0c Rename client.WithOptTransport (#464)
* Update apiv1_client_opts.go
* Update apiv1_client_test.go
2024-01-25 10:06:35 -08:00
corey-aloia
b5ccd3da51 [Rest Client] Use options for client.New (#463)
* adding in clientopts to the rest client

Signed-off-by: Corey Aloia <corey.aloia@sap.com>
2024-01-25 09:46:31 -08:00
dependabot[bot]
2d409bb2b1 build(deps): bump msgpackr from 1.9.9 to 1.10.1 in /ui (#461)
Bumps [msgpackr](https://github.com/kriszyp/msgpackr) from 1.9.9 to 1.10.1.
- [Release notes](https://github.com/kriszyp/msgpackr/releases)
- [Commits](https://github.com/kriszyp/msgpackr/compare/v1.9.9...v1.10.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-29 09:11:44 -08:00
James Hillyerd
4262b34a20 enmime: update to 1.1.0 (#459) 2023-12-15 17:45:24 -08:00
James Hillyerd
746f3bffbd actions: test build on windows (#458)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-12-13 17:02:24 -08:00
James Hillyerd
5a5864fde6 storage test: fix failures on windows (#457)
* storage test: close source reader
* fstore: properly close directory handle
2023-12-12 19:31:14 -08:00
James Hillyerd
e6e4e0987d storage: $ can be used in place of : in filestore path (#449) 2023-11-30 19:45:26 -08:00
James Hillyerd
f0473c5d65 docker: Give example of non-discard domain in greeting (#453)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-30 15:18:01 -08:00
James Hillyerd
288288ee85 git: do not treat golden files as text (#452) 2023-11-29 17:13:04 -08:00
James Hillyerd
32b83e6345 test: preserve SYSTEMROOT on windows (#451) 2023-11-29 16:36:40 -08:00
James Hillyerd
043551343c storage: fail startup if unable to create file store dir (#448)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-28 15:09:17 -08:00
James Hillyerd
c1d5d49126 event: Use pointers for InboundMessage addresses (#447)
* event: Use pointers for InboundMessage addresses

To ease conversions to/from MessageMetadata

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

* message: test StoreManager.MailboxForAddress()

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-22 17:28:33 -08:00
James Hillyerd
d2121a52a9 message: Prefer To header for BeforeMessageStored event (#446)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-22 16:58:14 -08:00
James Hillyerd
3e94aacc20 message: improve manager test coverage (#438) 2023-11-21 15:40:14 -08:00
James Hillyerd
41889ee83a test: impl StoreStub.PurgeMessages (#444)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-21 14:56:21 -08:00
James Hillyerd
1b5a783dbd test: impl StoreStub.MarkSeen (#443)
* test: impl StoreStub.MarkSeen

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

* continue to Message interface in StoreStub

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

* test errors

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-18 11:21:09 -08:00
James Hillyerd
208d20582e test: Add tests for StoreStub (#440)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-17 16:34:49 -08:00
James Hillyerd
2a65c9beaa github actions: fix for #441, skip-pkg-cache: true (#442)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-17 16:00:01 -08:00
James Hillyerd
f411a42f90 github actions: work around golangci errors (#441)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-17 15:49:12 -08:00
James Hillyerd
13c39c8c0f github actions: test coverage on pushes to main (#439)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-17 10:45:55 -08:00
James Hillyerd
1088ccb8d1 luahost: add type check TODOs (#436)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-13 13:26:41 -08:00
James Hillyerd
d304cbd88b github actions: add missing Go linter (#428) 2023-11-13 12:30:32 -08:00
James Hillyerd
e56638cbac various: resolve linter errors in a number of pkgs (#434)
- webui: resolve linter errors
- msghub: resolve linter errors
- policy: resolve linter errors
- extension: resolve linter errors
2023-11-13 12:21:19 -08:00
James Hillyerd
843cb8a015 lifecycle: Don't create multiple notify channels (#435)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-13 11:54:14 -08:00
James Hillyerd
535438342e server: resolve linter errors (#433)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-13 11:34:05 -08:00
James Hillyerd
e22ed26633 storage: resolve linter errors (#432)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-13 11:22:49 -08:00
James Hillyerd
1ce1674861 client: resolve linter errors (#431)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-13 11:11:08 -08:00
James Hillyerd
7ae7d29741 rest: resolve linter errors (#430) 2023-11-12 19:32:43 -08:00
James Hillyerd
86d762ac88 stringutil: fix golint comment format error (#429)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-12 13:21:27 -08:00
James Hillyerd
85e1c2c7d7 message: Verify empty mailbox list does not error (#424)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-12 13:10:29 -08:00
James Hillyerd
3e06050771 docker-build action: fix syntax error, use env. for conditionals (#427)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-12 12:30:35 -08:00
James Hillyerd
72adb5561d docker-build action: only push to registry on main branch and v* tag pushes (#426) 2023-11-12 11:49:00 -08:00
Cyd
20ef8af047 Reject invalidomain with wildcards (#412)
Co-authored-by: Cyril DUPONT <cyd@9bis.com>
2023-11-12 09:42:20 -08:00
James Hillyerd
d7c538a210 doc: add INBUCKET_SMTP_REJECTORIGINDOMAINS docs (#423)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-10 13:13:21 -08:00
James Hillyerd
ebd4b9504b ui: status: Display reject-origin-domains config (#422)
For #380

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-09 15:13:05 -08:00
James Hillyerd
1d102a68b8 ui: update webcomponentsjs dep (#421)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-07 14:30:20 -08:00
James Hillyerd
11b581bbb5 ui: update parcel deps (#420)
* ui: update parcel dep

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

* ui: update parcel-namer-rewrite dep

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-07 14:12:24 -08:00
James Hillyerd
d1e52ad971 ui: update elm dep (#419)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-07 13:43:26 -08:00
James Hillyerd
4a6b727cbc lua: bind BeforeMessageStored function (#418)
* lua: Restore missing test log output
* lua: Use logger for test assert_async output
* lua: add InboundMessage bindings
* lua: bind BeforeMessageStored function

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-06 18:10:02 -08:00
James Hillyerd
01fb161df8 extension: BeforeMessageStored event to rewrite envelope (#417)
* extension: add InboundMessage type
* manager: fires BeforeMessageStored event
* manager: Reacts to BeforeMessageStored event response
* manager: Apply BeforeMessageStored response fields to message

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-06 14:53:38 -08:00
James Hillyerd
0cb62af074 message: Add test for recipient policy (#416)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-03 12:34:58 -07:00
James Hillyerd
3731837127 docker: don't build until after PR submitted (#415) 2023-11-02 17:46:26 -07:00
James Hillyerd
74a27875e9 message: migrate more delivery logic into manager.go (#414)
* message: migrate more delivery logic into manager.go

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

* manager: tidy up a few things

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-11-01 13:54:27 -07:00
James Hillyerd
b655c0cc11 smtp: Use enmime.DecodeHeaders for better performance (#413)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-10-30 17:10:09 -07:00
James Hillyerd
163a84f353 lua: fix incorrect function name (#409)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-10-23 17:05:56 -07:00
James Hillyerd
d6c23df241 lua: Expose logger object (#407)
Allows Lua scripts to add entries to Inbuckets log

Closes #327

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-10-23 16:06:19 -07:00
Saulius Gurklys
b1acff08a3 UTC format timestamps in headers (#404) (#406)
This change makes all header timestamps (e.g. "Received") to use
UTC time zone.

This way the length of such headers do not depend on local time zone
which if effect fixes #404.

Signed-off-by: Saulius Gurklys <s4uliu5@gmail.com>
2023-10-21 16:22:49 -07:00
dependabot[bot]
3d162549b1 build(deps): bump golang.org/x/net from 0.15.0 to 0.17.0 (#400)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.15.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.15.0...v0.17.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>
2023-10-18 11:42:35 -07:00
James Hillyerd
d2fad433d7 Dockerfile: add missing node-gyp build dep to node:18 image (#403)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-10-16 16:00:35 -07:00
Benson Margulies
beb5abc62d Add a ForceTLS flag for SMTP. (#402)
When this is enabled, the server listens with TLS instead of waiting for
STARTTLS.

Signed-off-by: Benson Margulies <bimargulies@google.com>
2023-10-16 14:31:16 -07:00
James Hillyerd
3709aa8b51 message: Include inlines when returning attachments (#398)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-14 14:29:33 -07:00
James Hillyerd
63e47a4e74 web: Redirect base path prefix to prefix/ (#397)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-13 12:18:08 -07:00
James Hillyerd
5eb9592637 docker: fix insignificant ENV typo (#396)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-13 12:07:53 -07:00
James Hillyerd
9eabf94c48 goreleaser: remove deprecated rlcp option (#395)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-13 11:43:14 -07:00
James Hillyerd
0128be1f64 Fix Docker image link (#394)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-13 10:15:04 -07:00
James Hillyerd
d5553030d2 Fix service paths (#393)
* service: fix ExecStart path for pre-built packages

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

* packaging: fix goreleaser and service paths

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2023-09-12 15:33:04 -07:00
James Hillyerd
f070347535 goreleaser: use default snapshot name template (#392)
Our manually configured template was breaking debian package snapshots,
version must start with a number
2023-09-12 14:33:15 -07:00
James Hillyerd
cafd2c3d66 Add names to better distinguish workflows (#391) 2023-09-11 13:47:53 -07:00
James Hillyerd
dcd60b47dd update go module dependencies (#390) 2023-09-11 13:28:25 -07:00
dependabot[bot]
6a30a294c6 build(deps): bump semver from 5.7.1 to 5.7.2 in /ui (#373)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-09 12:57:00 -07:00
James Hillyerd
9836c0ffbb go: update mod and imports to correctly reflect major version (#388) 2023-09-09 12:36:21 -07:00
James Hillyerd
558f3de083 nix shell: use go 1.20 (#387) 2023-09-09 11:48:53 -07:00
Benson Margulies
00736cc704 pop3 TLS: don't do server-side handshake explicitly... (#386) 2023-09-07 09:56:19 -07:00
Benson Margulies
9f0fef3180 Implement STLS for pop3 (#384) 2023-09-05 14:28:26 -07:00
Shantanu Gadgil
f1dadba1b2 inbucket cmd: add version flag (#385) 2023-08-31 10:00:41 -07:00
Cyd
06ec140e72 add reject from origin domain feature (#375)
Add a new feature to be able to reject email *from* specific domains.

Co-authored-by: Cyril DUPONT <cyd@9bis.com>
2023-08-26 11:05:20 -07:00
James Hillyerd
7c13a98ad2 ui: bump nodejs to 18 (LTS) (#381) 2023-08-25 12:11:45 -07:00
guangwu
0ae452ed17 chore: remove refs to deprecated io/ioutil (#376)
Signed-off-by: guoguangwu <guoguangwu@magic-shield.com>
2023-08-07 16:25:36 -07:00
Carlos Tadeu Panato Junior
926f9f3804 Few updates in ci jobs and go.mod/dockerfile (#372)
* update go.mod to 1.20
* clean up and format ci jobs
* update go and alpine images in dockerfile

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

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

* Introduce MessageID for deletes, instead of recycling header

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

* Update UI for monitor V2 API

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

---------

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

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

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

* socket and UI support message deletes

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

* use Delete naming for consistency

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

---------

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

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

* lua: test after_message_deleted func

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

---------

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

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

* Wire ExtHost into storage system
imports

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

* storage/file: emit deleted events

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

* storage/mem: emit deleted events

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Handle forward-path route spec

* Validate IP addr "domains"

* Forward-path test cases

* Add integration test

* Add IPv4 recip swaks test

* Special case domain mailbox extraction

* add IPv6 swaks test

* Formatting

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

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

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

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

* Add QEMU step to build for multi arch

* Pin qemu action version
2022-02-25 08:56:18 -08:00
James Hillyerd
5322462899 Update changelog for 3.0.1-rc2
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-23 17:42:32 -08:00
Daniel Simkus
5def9ed183 Add arm and arm64 builds (#256)
* Add arm and arm64 builds

Signed-off-by: danielsimkus <daniel.simkus@carandclassic.com>

* Added arm and arm64 to client, and switched to goarm 7

Signed-off-by: danielsimkus <daniel.simkus@carandclassic.com>
2022-01-22 09:50:37 -08:00
James Hillyerd
357589d90e Rename master branch to main (#255)
* Update contributing guide, remove git-flow references

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

* Update changelog for main branch rename

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

* Update github actions for branch rename

* Update README build badges

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

* Update README for new branch names

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

* Note branch rename in change log

Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 17:16:44 -08:00
James Hillyerd
b664bcfc4c Update changelog for 3.0.1-rc1 2022-01-17 13:28:00 -08:00
James Hillyerd
ffd13e2ee7 Update go deps (#253)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 13:17:50 -08:00
James Hillyerd
747775b8f2 Update npm deps (#252)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 12:48:44 -08:00
dependabot[bot]
2c0d942c76 build(deps): bump follow-redirects from 1.14.0 to 1.14.7 in /ui (#247)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.0 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.0...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-17 12:24:06 -08:00
James Hillyerd
e7263439d5 Correct goreleaser config (#250)
* Correct glob patterns to include missing index.html
* Remove broken homebrew config
2022-01-17 12:19:37 -08:00
James Hillyerd
cb6f99c487 Merge branch 'release/3.0.0' 2021-10-02 10:52:59 -07:00
James Hillyerd
04fb58e15e Update CHANGELOG for final 3.0.0 2021-09-19 11:34:43 -07:00
James Hillyerd
f11ad55474 Merge tag 'v3.0.0-rc4' into develop
v3.0.0-rc4

Fixed
- Various MIME header decoding improvements

Changed
- Bump Go version to 1.17 (#233)
2021-08-22 13:37:26 -07:00
James Hillyerd
26939f2bf6 Merge branch 'release/3.0.0-rc4' 2021-08-22 13:36:32 -07:00
James Hillyerd
05a3b1742a Update CHANGELOG for rc4 2021-08-22 13:32:47 -07:00
James Hillyerd
867d5f5d7f Update npm deps (#235) 2021-08-22 13:25:42 -07:00
James Hillyerd
8e34a21dc6 Update Go dependencies, incl enmime (#234) 2021-08-22 12:54:26 -07:00
James Hillyerd
8869acef0b Bump Go to 1.17 (#233)
* Bump Go to 1.17

* update chglog
2021-08-22 12:31:13 -07:00
James Hillyerd
752d5c9668 docker: tag versions with latest (#232) 2021-08-07 10:56:40 -07:00
James Hillyerd
091e26c467 docker-build should also run on tags (#230) 2021-08-01 13:19:19 -07:00
James Hillyerd
6593a36b48 Merge tag 'v3.0.0-rc3' into develop
Release is for CI/CD changes only.
2021-08-01 13:02:26 -07:00
James Hillyerd
68ef2d9873 Merge branch 'release/3.0.0-rc3' 2021-08-01 13:01:35 -07:00
James Hillyerd
ab988caf6b Add rc3 to change log 2021-08-01 13:00:28 -07:00
James Hillyerd
fa62220d98 Add ghcr.io to docker metadata images 2021-08-01 12:29:26 -07:00
James Hillyerd
1ecf424975 login to GitHub container registry 2021-08-01 12:14:28 -07:00
James Hillyerd
3342938dd4 Update to docker-push-action v2 w/ metadata, buildx (#228)
Docker Hub no longer builds for us, so we need to switch to GHA
2021-08-01 11:57:46 -07:00
James Hillyerd
6be1655723 Update to docker-push-action v2 w/ metadata, buildx (#228)
Docker Hub no longer builds for us, so we need to switch to GHA
2021-08-01 11:52:17 -07:00
James Hillyerd
1465e6fb49 Merge tag 'v3.0.0-rc2' into develop
Added:

- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)

Fixed:

- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
2021-07-31 16:41:35 -07:00
James Hillyerd
21991cbfc7 Merge branch 'release/3.0.0-rc2' 2021-07-31 16:40:17 -07:00
James Hillyerd
7138a97935 Update change log for 3.0.0-rc2 2021-07-31 16:32:01 -07:00
James Hillyerd
beee68fc5d Update change log 2021-07-31 16:26:47 -07:00
James Hillyerd
9e2af71743 Upgrade node deps (#227)
* bump node deps

* npm audit fix
2021-07-31 16:09:30 -07:00
James Hillyerd
a2c4292fc1 update go deps (#226) 2021-07-31 15:53:38 -07:00
James Hillyerd
2016142747 smtp: allow empty user & pass during AUTH LOGIN (#225) 2021-07-31 10:38:48 -07:00
James Hillyerd
4f9f961cac smtp: fix formatting (#224) 2021-07-31 10:32:08 -07:00
Nelson Efrain A. Cruz
bf8536abb3 Adds dark mode support (#218)
* Adds dark mode support

Updates the css to support dark mode via media query.
The dark theme its heavily inspired on the new dark mode for google.com.
2021-07-20 08:07:06 -07:00
James Hillyerd
985f2702f2 Fix command line length bug (#221)
* handler: Don't fail on 8 character command lines

Fixes #214

* handler: Test that STARTTLS is parsed correctly.
2021-07-11 12:00:28 -07:00
James Hillyerd
11f3879442 goreleaser: update nfpm config to use contents attrib (#220)
fixes #219

Signed-off-by: James Hillyerd <james@hillyerd.com>
2021-07-11 10:05:00 -07:00
James Hillyerd
8562c55c98 nix: add elm-json for updating pkgs 2021-05-06 12:27:01 -07:00
James Hillyerd
e3066bb535 Update nodejs dependencies (#209)
* node: Update top level deps

* node: Audit fix
2021-05-06 11:22:50 -07:00
James Hillyerd
35ab31efbc Update go deps (#208) 2021-05-06 10:35:53 -07:00
James Hillyerd
81edf40996 store_test: Fix t.Fatal non-test goroutine lint error 2021-05-06 09:58:30 -07:00
James Hillyerd
c64e7a6a6c Revert "Add support for AUTH, closes #62" (#206)
This reverts commit 261bbef426.  It was merged to directly master by mistake, and should have gone through the normal release process.
2021-05-03 20:56:10 -07:00
James Hillyerd
4bd64563f2 Bump nodejs to 14.x (#203) 2021-05-01 16:50:43 -07:00
James Hillyerd
66dec49a49 Bump Go version to 1.16 (#202)
* bump go version
* Docker: bump go/alpine version
2021-05-01 14:16:59 -07:00
James Hillyerd
649e3743e0 Migrate off Travis CI (#201)
Adds generic Go matrix build with coverage, removes Travis config.
2021-05-01 13:49:41 -07:00
Timur Makarchuk
c096f018d6 Add support for AUTH, closes #62
* Add PLAIN and LOGIN auth support
2021-04-10 13:58:18 -07:00
Timur Makarchuk
261bbef426 Add support for AUTH, closes #62
* Add PLAIN and LOGIN auth support
2021-04-10 13:45:09 -07:00
Stuart Skelton
3c5960aba0 Avoid potential click jacking (#190) 2020-11-19 08:16:01 -08:00
James Hillyerd
7f430f2bde Merge tag 'v3.0.0-rc1' into develop
Added:

- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights

Changed:

- The UI now includes the Open Sans webfont instead of relying on browser/OS
  fonts
2020-09-24 20:59:30 -07:00
James Hillyerd
c480fcb341 Merge branch 'release/3.0.0-rc1' 2020-09-24 20:58:53 -07:00
James Hillyerd
e74f5e5116 Update changelog for 3.0.0-rc1 2020-09-24 20:54:07 -07:00
James Hillyerd
6ce045ddb7 Update changelog 2020-09-24 20:43:56 -07:00
James Hillyerd
9b03c311db ui: Replace Mailbox Session use with ServeUrl (#185)
Plus a couple UI padding tweaks
2020-09-24 15:59:12 -07:00
James Hillyerd
ebd25a60e1 ui: Remove to do comments, must keep session.router 2020-09-24 11:19:04 -07:00
James Hillyerd
7c87649579 ui: Convert Layout to use Effects 2020-09-23 23:00:29 -07:00
James Hillyerd
e56365b9a0 Update deps (#184)
* backend: update Go dependencies
* frontend: update npm dependencies
2020-09-23 21:39:27 -07:00
James Hillyerd
698b0406c8 Readme updates (#183)
* Add docker build badge
* Rephrase things
* add dev guide link
* Remove brew tap section until #68 is fixed
2020-09-23 20:43:51 -07:00
James Hillyerd
361bbec293 ui: Keyboard accessibility focus highlights (#180)
* Focus indication for mailbox message list
* Add focus for monitor message list
2020-09-22 14:11:06 -07:00
James Hillyerd
407ae87a3b ui: Add refresh button to mailbox page (#179)
`socketConnected` is not implemented, but will be used when we implement #92
2020-09-21 20:11:32 -07:00
James Hillyerd
4648d8e593 ui: Use OpenSans font (#178) 2020-09-21 16:16:00 -07:00
James Hillyerd
5c5b0f819b Effects refactor continued (#177)
* Use Effects instead of replaceUrl in Mailbox
* Add Effect.navigateRoute to handle monitor message clicks
* Add a focusModal effect for mailbox purge
* Remove temporary Cmd wrapper Effect
2020-09-13 17:08:11 -07:00
James Hillyerd
8adfd82232 ui: add npm run clean script 2020-09-13 10:39:16 -07:00
James Hillyerd
2162a4caaa ui: Add an Effect system to handle global state and Elm Cmds (#176)
All pages now leverage Effects for most of their Session and Cmd requests. More work required for routing and other lingering Cmd use.
2020-09-12 19:45:14 -07:00
James Hillyerd
cf4c5a29bb ui: Force file watch on dev server
file watch stopped working
2020-09-12 16:32:40 -07:00
James Hillyerd
6598b09114 ui: Start dev server with default host, not 0.0.0.0
0.0.0.0 does not work well with WSL2 on Windows 10
2020-09-06 16:38:33 -07:00
James Hillyerd
ce5bfddaa5 Migrate release process from travis to github (#175)
* set fetch depth to 0
* Only snapshot when not tagged
* Run deploy for v* tags
* travis: remove deploy stage
2020-09-05 14:21:42 -07:00
James Hillyerd
2934d799ef Add a GitHub workflow for building a snapshot release 2020-09-04 15:06:57 -07:00
James Hillyerd
8a07a24828 Merge tag 'v3.0.0-beta3' into develop 2020-09-04 12:45:45 -07:00
James Hillyerd
2408ace6c2 Merge branch 'release/3.0.0-beta3' 2020-09-04 12:45:14 -07:00
James Hillyerd
1a5db5b5f8 update CHANGELOG 2020-09-04 12:44:50 -07:00
James Hillyerd
f712f5b0f3 Update frontend dependencies (#174)
* ui: update top level npm deps

* ui: update webpack dependencies

* ui: update elm-webpack-loader

* ui: npm audit fix
2020-08-30 18:48:41 -07:00
James Hillyerd
f0520b88c5 Update backend and docker dependencies (#173)
* backend: update dependencies
* travis: go 1.15
* docker: bump to go 1.15, alpine 3.12
2020-08-30 13:18:58 -07:00
James Hillyerd
5a0c4778cb Set base path in index.html (#172)
- Create a new index-dev.html for webpack live server
- Update Go+index.html to set <base href>
- Fixes #171
2020-08-29 19:06:21 -07:00
James Hillyerd
289b38f016 Add configurable base path for reverse proxy use (#169)
* ui: Refactor routing functions into Router record
* ui: Store base URI in AppConfig
* ui: Use basePath in Router functions
* backend: Add Web.BasePath config option and update routes
* Tweaks to get SPA to bootstrap basePath configured
* ui: basePath support for apis/serve
* ui: basePath support for message monitor
* web: Redirect requests to / when basePath configured
* doc: add basepath to config.md
* Closes #107
2020-08-09 15:53:15 -07:00
James Hillyerd
316a732e7f cmd, pkg: add line breaks to several go source files 2020-07-26 11:59:27 -07:00
Sascha Andres
f0bc5741f3 feat: update gorilla websocket (#167)
Closes #144
2020-07-25 10:34:15 -07:00
Sascha Andres
046de42774 allow empty envelope (#166)
* feat: allow empty MAIL FROM

Closes #164
2020-07-25 10:23:31 -07:00
James Hillyerd
860045715c docker-run.sh: Update image repo 2020-06-28 11:02:49 -07:00
James Hillyerd
001e9fec58 Rollback #153 as it breaks storage volumes, closes #27 (#161)
Keeping tzdata pkg
2020-06-28 11:00:51 -07:00
James Hillyerd
2e0b7cc097 Merge docker github action for #160 2020-06-28 09:31:07 -07:00
James Hillyerd
b0bbf2e9f5 github: Add action to test build docker image 2020-06-28 09:27:45 -07:00
Martijn Suijlen
3372ade61b Docker image should run non-root (#153)
Changed the Dockerfile so that there is a Inbucket user (and group). This will allow the container to be executed a the Inbucket user in stead of ROOT (security best practices)

If the user wants to use a different greeting.html file he can use the environment variable to define a different one. For now we just use the greeting.html from the defaults directory.

* Permissions for /start-inbucket.sh file
* Added timezone data so you can set the timezone in the image
* Updated Docker greeting.html file to include some basic instructions
* Updated to alpine 3.11
* Updated to golang 1.14
* Updated the required packages
2020-06-26 08:38:27 -07:00
James Hillyerd
62dd540be5 Merge branch 'feature/mailbox-timer' into develop 2020-04-12 16:29:31 -07:00
James Hillyerd
65a6ab2b4f ui: Simplify updateMarkMessageSeen 2020-04-11 16:11:28 -07:00
James Hillyerd
9e1da20782 ui: Update Mailbox to use Timer module 2020-04-11 15:32:35 -07:00
James Hillyerd
930801f6da Merge branch 'feature/mouse-out-delay' into develop 2020-04-05 17:47:57 -07:00
James Hillyerd
4fc8d229eb ui: impl Timer.schedule function 2020-04-05 17:27:22 -07:00
James Hillyerd
e8e506f870 ui: Refactor Timer into it's own module. 2020-04-05 16:22:16 -07:00
James Hillyerd
8a3d291ff3 ui: Improve layout menu function
- Rename menu to mainMenu for clarity
- Rename recent to recentMenu
- Add a mouseOut timer to recentMenu
2020-04-05 15:30:08 -07:00
James Hillyerd
107b649738 docker: Capture stderr in healthcheck 2020-03-29 20:49:38 -07:00
Martijn Suijlen
c91a3ecd41 docker: Add a healthcheck (#152)
Use the INBUCKET_WEB_ADDR value to get the port number of Inbucket using 'cut'. If the variable is not set, use the default value (and that is 0.0.0.0:9000). Healtcheck will check the exit code of the command executed (0=HEALTHY)
2020-03-29 20:40:39 -07:00
James Hillyerd
2c74268014 docker: Fix + some tweaks, fixes #155
- Reorder deps & builds
- Download elm binary
- g++ and python3 for node-gyp
- Bump go & alpine versions
- Use npm ci
2020-03-29 20:15:33 -07:00
James Hillyerd
da63e4d77a travis: s/-/_/ for elm deps 2020-03-29 19:32:41 -07:00
James Hillyerd
4a90b37815 ui: Implement modal focus trap 2020-03-29 19:26:59 -07:00
James Hillyerd
cabbdacb89 Merge branch 'feature/linter-fixes' into develop 2020-03-29 12:55:44 -07:00
James Hillyerd
baad19e838 ui: Add function signature to accept and store policies 2020-03-29 12:21:16 -07:00
James Hillyerd
c520af4983 ui: Linter dead code elimination 2020-03-29 11:54:12 -07:00
James Hillyerd
c312909112 ui: Cons related linter fixes 2020-03-29 11:41:23 -07:00
James Hillyerd
083b65c9bc ui: Ignore elm make generated index.html 2020-03-29 11:39:10 -07:00
James Hillyerd
59ae2112f7 ui: Import fixes for src directory 2020-03-29 11:35:22 -07:00
James Hillyerd
1a45179e31 ui: Linter import fixes for Page directory 2020-03-29 11:28:47 -07:00
James Hillyerd
2b857245f7 ui: Fix linter warnings in Data dir 2020-03-29 11:13:31 -07:00
James Hillyerd
9573504725 Merge branch 'feature/padding' into develop 2020-03-22 22:10:54 -07:00
James Hillyerd
c21066752f travis: Switch to ubuntu bionic w/ preinstalled nodejs 2020-03-22 21:53:19 -07:00
James Hillyerd
66c95baf05 ui: Horizontally center content on wide displays 2020-03-22 21:20:38 -07:00
James Hillyerd
22a7789b7b ui: Prevent mailbox dropdown overflow 2020-03-22 20:40:02 -07:00
James Hillyerd
d2da53cc0f ui: Convert main.css to mobile first 2020-03-22 18:49:45 -07:00
James Hillyerd
bfac9a0cc2 nix: add elm lang server, for nixos newer than 19.09 2020-03-22 17:48:02 -07:00
James Hillyerd
a64429ae61 Merge branch 'feature/elm-0191' into develop 2020-03-21 13:50:56 -07:00
James Hillyerd
2436f2e3de ui: bump elm filesize, date-format versions 2020-03-21 13:41:37 -07:00
James Hillyerd
fc76ce74cb ui: bump elm indirect versions 2020-03-21 13:36:15 -07:00
James Hillyerd
eef4bbdb01 ui: bump elm core libs 2020-03-21 13:29:25 -07:00
James Hillyerd
201987f6a8 ui: Upgrade to elm 0.19.1, bump all JS deps 2020-03-21 13:07:59 -07:00
James Hillyerd
45d9d2af39 travis: Update to Go 1.14.x 2020-03-21 10:04:24 -07:00
Fred Cox
12802e93cb Fix var name for tls cert (#146) 2019-09-17 08:51:07 -07:00
James Hillyerd
0956a13618 release: Modernize goreleaser config 2019-08-17 16:47:02 -07:00
James Hillyerd
de4bb991dd Merge tag 'v3.0.0-beta2' into develop
Added
- Ability to name mailboxes after domain of email recipient, set via
  `INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.

Changed
- Updated JavaScript dependencies.
- Updated Go dependencies.
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10

Fixed
- URLs to view/download attachments from REST API, #138
- Support for late EHLO, #141
2019-08-17 16:11:41 -07:00
James Hillyerd
14f0895ae7 Merge branch 'release/3.0.0-beta2' 2019-08-17 16:11:12 -07:00
James Hillyerd
8bb01570ef Update changelog 2019-08-17 16:11:02 -07:00
James Hillyerd
3a1c757d04 smtp: Handle late EHLO, fixes #141 2019-08-17 15:46:28 -07:00
James Hillyerd
d8474d56e5 Merge branch 'feature/readme' into develop 2019-08-17 15:31:16 -07:00
James Hillyerd
eef45a4473 readme: Add Docker info 2019-08-17 15:29:05 -07:00
James Hillyerd
91d19308fe readme: Update build instructions, closes #127 2019-08-17 15:20:40 -07:00
James Hillyerd
e359c0b030 docker: go 1.11 -> 1.12, alpine linux 3.8 -> 3.10 2019-08-17 13:00:01 -07:00
James Hillyerd
a73ffeabd3 rest: Update attachments to 3.0 URL scheme, closes #138 2019-08-17 12:07:20 -07:00
James Hillyerd
0b3f4eab75 backend: Update dependencies 2019-08-10 17:54:09 -07:00
James Hillyerd
7ea4798e77 backend: Update to enmime 0.6.0 2019-08-10 17:13:57 -07:00
James Hillyerd
070de88bba ui: JS dependency updates 2019-08-10 16:28:48 -07:00
James Hillyerd
383386d5fb Update lodash 2019-08-10 16:00:04 -07:00
James Hillyerd
a3e2c5247e Update elm + elm-webpack-loader 2019-08-10 15:56:46 -07:00
James Hillyerd
702f9ef48e Update top-level JS deps 2019-08-10 14:48:54 -07:00
James Hillyerd
ac4501ba35 Update change log 2019-08-10 14:31:21 -07:00
Matt John
c78656b400 Add ability to name mailboxes after domain of email (#140)
* Add ability to name mailboxes after domain of email
* Update argument documentation for 'domain' filtering
* Update address policy to verify domain against RFC and return error on invalid domain
2019-08-10 14:13:21 -07:00
James Hillyerd
b6a6cc6708 Merge tag 'v3.0.0-beta1' into develop
Release 3.0.0-beta1
2019-03-14 21:29:19 -07:00
James Hillyerd
a17fa256a2 Merge branch 'release/3.0.0-beta1' 2019-03-14 21:26:15 -07:00
James Hillyerd
7ea8e2fc03 Release 3.0.0-beta1 2019-03-14 21:15:09 -07:00
James Hillyerd
a0b6f0692d Merge branch 'feature/gh-org' into develop for #126 2019-02-24 10:36:31 -08:00
James Hillyerd
c1b7e3605c goreleaser: update github owner to inbucket 2019-02-24 10:35:38 -08:00
James Hillyerd
2b3dd51e71 docs: rename to github.com/inbucket/inbucket 2019-02-24 10:09:33 -08:00
James Hillyerd
e4c48a0705 go code: rename to github.com/inbucket/inbucket 2019-02-24 09:43:10 -08:00
James Hillyerd
5c885a067a ui: Update npm deps 2019-02-24 09:02:52 -08:00
James Hillyerd
71b3de59af ui: Add search clear button 2019-02-24 08:42:33 -08:00
James Hillyerd
fc95f6e57f ui: Add polyfill for webcomponents 2019-02-18 19:28:44 -08:00
James Hillyerd
e5e1c39097 ui: Upgrade to NodeJS 10.x 2019-02-18 17:01:18 -08:00
James Hillyerd
f1b85be23a ui: Have dev-server bind to 0.0.0.0 2019-02-18 16:31:54 -08:00
James Hillyerd
aaf8eb5ec1 ui: Combine margin/padding 2019-02-18 15:48:08 -08:00
James Hillyerd
18b85877ab ui: Ignore submit of empty mailbox input 2019-02-18 13:04:21 -08:00
James Hillyerd
cd89d77d9f ui: Unknown routes redirect to Home 2019-02-18 12:51:49 -08:00
James Hillyerd
a54e0f2438 ui: Clear flash when changing routes 2019-02-18 12:35:27 -08:00
James Hillyerd
3738ccc11d ui: Remove unnecessary Session params from Mailbox 2019-02-18 11:09:36 -08:00
James Hillyerd
a6cdd30fb1 Merge branch 'feature/keybd-nav' into develop 2019-02-17 14:05:19 -08:00
James Hillyerd
a467829103 ui: Convert hamburger to button 2019-02-17 13:40:56 -08:00
James Hillyerd
b2255fefab ui: Allow keyboard navigation of message list 2019-02-17 12:59:05 -08:00
James Hillyerd
34799b9a04 ui: Move ClearFlash & ViewMailbox into Layout 2019-02-17 12:21:20 -08:00
James Hillyerd
cfbd30d8b0 ui: Give Layout it's own Model/Msg 2019-02-17 11:22:59 -08:00
James Hillyerd
7cd45ff3c7 ui: Allow tab+enter nav of monitor messages 2019-02-17 08:55:59 -08:00
James Hillyerd
3c2b302a5f ui: Fix Source & Raw HTML tab nav 2019-02-17 08:55:59 -08:00
James Hillyerd
35969e0b0f ui: Reorg navbar CSS 2019-02-17 08:55:59 -08:00
James Hillyerd
d933d591d8 ui: Add button to open recent menu via keyboard 2019-02-15 21:28:29 -08:00
James Hillyerd
b82cafc338 ui: mailbox input to end of nav for kbd sanity 2019-02-15 18:47:05 -08:00
James Hillyerd
f739ba90a1 Merge branch 'feature/mobile-msg' into develop 2019-02-09 13:56:42 -08:00
James Hillyerd
6724c86181 ui: Add message back/close button for mobile 2019-02-09 13:44:46 -08:00
James Hillyerd
645feeaf85 ui: convert mailbox.css to mobile-first 2019-02-09 13:16:43 -08:00
James Hillyerd
99df27ee34 ui: extract mailbox.css 2019-02-09 13:16:43 -08:00
James Hillyerd
5ae69314dd Merge branch 'feature/mobile-nav' into develop 2019-01-26 14:29:01 -08:00
James Hillyerd
3df655d611 ui: Fix desktop navbar rendering 2019-01-26 14:27:38 -08:00
James Hillyerd
ae76ecef00 ui: Passable mobile menu, but desktop broken 2019-01-26 13:26:16 -08:00
James Hillyerd
37f05b08c5 ui: Extract navbar.css 2019-01-26 11:11:38 -08:00
James Hillyerd
79fdc58567 docker: set UIDIR, closes #134 2019-01-21 10:49:19 -08:00
James Hillyerd
d16699f59f travis: Build Elm UI during deploy 2019-01-06 18:48:18 -08:00
James Hillyerd
9ca179e249 travis: Make Elm part of the build matrix 2019-01-06 18:09:52 -08:00
James Hillyerd
07e75495e8 build: Update goreleaser for Elm UI 2019-01-06 13:57:20 -08:00
James Hillyerd
683ce1241e ui: Comma separate To list 2019-01-06 11:30:45 -08:00
James Hillyerd
9815a66575 ui: Implement clear button on monitor 2019-01-01 14:45:25 -08:00
James Hillyerd
8e04ce1fec ui: Fix monitor scrolling 2019-01-01 14:27:31 -08:00
James Hillyerd
6287f5fe9c ui: Truncate monitor after 500 messages, closes #87 2019-01-01 13:48:41 -08:00
James Hillyerd
f47e2cfcc2 ui: Reimplement message monitor as web component, closes #128 2019-01-01 13:23:31 -08:00
James Hillyerd
dbdc60a0fb message, webui: Fix QP address rendering, closes #117 2019-01-01 10:04:37 -08:00
James Hillyerd
c0a878db47 config: Remove mailbox prompt, no longer used 2019-01-01 09:36:39 -08:00
James Hillyerd
0ea18cbe2b Merge branch 'feature/layout' into develop 2018-12-31 16:48:30 -08:00
James Hillyerd
986377b531 ui: Clarifying renames 2018-12-31 16:47:49 -08:00
James Hillyerd
fac44b7753 ui: rename Page -> Layout 2018-12-31 16:28:22 -08:00
James Hillyerd
c977ded5ba ui: Reimplement session storage in Main 2018-12-31 16:16:20 -08:00
James Hillyerd
c2109a8df0 Merge branch 'feature/config-cookie' into develop 2018-12-31 15:29:15 -08:00
James Hillyerd
321c5615a5 ui: Respect monitor visible config option 2018-12-31 15:10:33 -08:00
James Hillyerd
c57260349b web + ui: Pass init cookie from server to client 2018-12-31 15:10:05 -08:00
James Hillyerd
91f3e08ce5 ui: Make CSS .well reusable for error/warn 2018-12-28 13:18:14 -08:00
James Hillyerd
c762c4d7a1 Merge branch 'feature/mime-errors' into develop 2018-12-28 11:47:26 -08:00
James Hillyerd
b954bea7c6 ui: Render MIME parsing errors 2018-12-28 11:21:51 -08:00
James Hillyerd
362ece171a webui: Add MIME errors to mailbox message 2018-12-28 10:28:36 -08:00
James Hillyerd
1922dc145d ui: Ouput web fonts to dist/static/ 2018-12-27 10:15:09 -08:00
James Hillyerd
4b9e432730 ui: Fix long message & list scrolling 2018-12-27 09:20:10 -08:00
James Hillyerd
78b36b0b14 Merge branch 'feature/session' into develop 2018-12-26 15:44:23 -08:00
James Hillyerd
2f7194835d ui: Remove Session from Main model 2018-12-26 15:22:45 -08:00
James Hillyerd
7c213cd897 ui: Embed Session into page models 2018-12-26 15:22:13 -08:00
James Hillyerd
6189b56b79 ui: Add fontawesome-free, use in status & mailbox 2018-12-23 15:28:23 -08:00
James Hillyerd
1a8b5184cd Merge branch 'feature/travis' into develop 2018-12-23 12:45:55 -08:00
James Hillyerd
55e11929c7 travis: Eliminate travis-deploy.sh 2018-12-23 12:45:40 -08:00
James Hillyerd
4dd3ad33f9 travis: Add Elm stage 2018-12-23 12:27:41 -08:00
James Hillyerd
92c89b98ee travis: Utilize build stages for deploy 2018-12-23 10:25:06 -08:00
James Hillyerd
51d732fa20 ui: Add raw HTML button to message 2018-12-15 21:30:13 -08:00
James Hillyerd
ffaf296faa ui: Make the error flash look nice 2018-12-15 21:17:37 -08:00
James Hillyerd
af3ed04100 ui: Move div.page into Page frame 2018-12-15 20:53:13 -08:00
James Hillyerd
caec5e7c17 ui: Add request context for error flash
- webui: Update mailbox, attachment paths
2018-12-15 20:16:20 -08:00
James Hillyerd
862aff434e Merge branch 'release/2.1.0' into master 2018-12-15 12:02:43 -08:00
James Hillyerd
6ef2beb821 CHANGELOG: release 2.1.0 2018-12-15 12:02:23 -08:00
James Hillyerd
6fd13a5215 Merge branch 'feature/cleanup' into develop #133 2018-12-15 10:01:07 -08:00
James Hillyerd
77ea66e0e6 web: Remove unused helpers 2018-12-15 09:51:37 -08:00
James Hillyerd
89886843bd web: Remove template related code 2018-12-15 09:20:57 -08:00
James Hillyerd
4894244d5c webui: Remove sessions & securecookie 2018-12-15 08:35:00 -08:00
James Hillyerd
d627da2038 webui: Remove unused routes/handlers 2018-12-15 08:35:00 -08:00
James Hillyerd
348eebe418 Add basic NixOS shell.nix file 2018-12-15 08:34:07 -08:00
James Hillyerd
bc427e237f ui: Move REST API calls into new Api module 2018-12-14 21:13:13 -08:00
James Hillyerd
f12a72871f ui: Mailbox purge prompts for confirmation 2018-12-12 22:12:35 -08:00
James Hillyerd
efe554bd77 ui: build tweaks
- ignore ui/dist dir
- dev-start.sh prompts for npm instead of elm-app
2018-12-12 20:21:40 -08:00
James Hillyerd
ecd7c9f6e6 Merge branch 'feature/docker' into develop 2018-12-11 19:48:33 -08:00
James Hillyerd
f0c9a1e7f4 ui: Add chunk hash to static js file name 2018-12-11 19:43:22 -08:00
James Hillyerd
1eba3164b5 ui: Remove built assets from repo 2018-12-11 19:25:23 -08:00
James Hillyerd
aae41ab79a docker: Build Elm UI in container 2018-12-11 19:24:38 -08:00
James Hillyerd
fc5cc4d864 ui: Display server uptime, scan completion time 2018-12-10 21:47:34 -08:00
James Hillyerd
9b3049562d ui: Display server configuration on status page 2018-12-09 16:23:15 -08:00
James Hillyerd
7a16f64ff0 ui: Make message & list scroll within viewport 2018-11-25 21:42:43 -08:00
James Hillyerd
6a95dfe5c6 ui: Invert mailbox media queries for desktop/mobile 2018-11-25 20:34:31 -08:00
James Hillyerd
22884378f3 ui: Stop using element IDs for styling 2018-11-25 19:52:28 -08:00
James Hillyerd
0cf97f5c58 ui: Style error flash, add close link 2018-11-25 09:22:59 -08:00
James Hillyerd
4eb2d5ae97 ui: Update browser URL after deleting a message 2018-11-23 17:52:10 -08:00
James Hillyerd
ce59c87250 ui: Eliminate javascript:void, closes #130 2018-11-23 17:26:09 -08:00
James Hillyerd
6215ce77dd ui: Show dates in browser local timezone 2018-11-23 16:47:22 -08:00
James Hillyerd
ba8e2de475 ui: Cleanup routing 2018-11-23 16:11:33 -08:00
James Hillyerd
0f9585a52b ui: Add session update logic into Session 2018-11-23 13:57:42 -08:00
James Hillyerd
e71377f966 ui: Refactor update and setRoute with updateWith 2018-11-23 13:02:10 -08:00
James Hillyerd
0c9cf81c94 main: bump enmime to v0.4.0 2018-11-21 21:45:27 -08:00
James Hillyerd
ff7fb8a781 docker: Fix build for webpack UI dist dir 2018-11-18 19:53:55 -08:00
James Hillyerd
6619764ea2 Merge elm 0.19 upgrade, closes #125 2018-11-18 19:47:31 -08:00
James Hillyerd
0d9952d35f ui: Commit UI build artifacts 2018-11-18 19:45:21 -08:00
James Hillyerd
5be2b57a12 ui: Easy renames and refactors 2018-11-18 19:41:32 -08:00
James Hillyerd
0ed0cd2d64 ui: Production optimization, basic babel config 2018-11-18 16:09:03 -08:00
James Hillyerd
74e7fd1179 ui: Fix external links 2018-11-18 11:36:49 -08:00
James Hillyerd
eaf41949d4 ui: Refactor page view/framing to handle titles 2018-11-18 10:51:26 -08:00
James Hillyerd
59062e1326 ui: Enable compile-error dev server overlay, SPA routing 2018-11-18 09:18:03 -08:00
James Hillyerd
019bd11309 ui: Parse POSIX millisecond date stamps 2018-11-17 21:11:41 -08:00
James Hillyerd
cf265dbe2c rest: Add posix-millis field for easier date parsing 2018-11-17 20:57:52 -08:00
James Hillyerd
c77cae2429 ui: Update README for npm/webpack 2018-11-17 19:18:39 -08:00
James Hillyerd
abd9ebeb35 ui: Use web components to render server-side HTML 2018-11-17 19:09:19 -08:00
James Hillyerd
f2cd3f92da ui: Upgrade to elm/http 2.0.0 2018-11-17 18:48:52 -08:00
James Hillyerd
e70900dd1a web: Implement SPA compatible routing 2018-11-17 14:38:04 -08:00
James Hillyerd
284dd70bc6 ui: minimal webpack 4 build w/ proxy & hot reload 2018-11-17 14:37:27 -08:00
James Hillyerd
fe20854173 ui: Get UI to compile with Elm 0.19 2018-11-17 14:36:47 -08:00
James Hillyerd
5ccdece541 ui: Remove built assets during elm upgrade 2018-11-17 14:19:10 -08:00
James Hillyerd
b67d5ba376 ui: Reformat with elm-format 0.8.1 2018-11-13 22:06:19 -08:00
James Hillyerd
ecd0c124d4 ui: Re-implement websockets with ports+JS 2018-11-13 21:27:58 -08:00
James Hillyerd
ac3a94412d Merge Elm user interface into develop, closes #105 2018-11-12 22:15:37 -08:00
James Hillyerd
8017e0ce57 docker: Update for elm UI artifacts 2018-11-12 22:05:48 -08:00
James Hillyerd
1f2d1a4622 ui: Commit build artifacts 2018-11-12 21:53:53 -08:00
James Hillyerd
bea3849c97 ui: Update README with dev instructions 2018-11-12 21:26:58 -08:00
James Hillyerd
2bbcef072a ui: Add greeting.html to Home 2018-11-12 20:38:00 -08:00
James Hillyerd
d1954cdd6f ui: Reformat with new elm-format 2018-11-12 20:38:00 -08:00
James Hillyerd
c92cd309bc ui: Add friendly date to Mailbox message view
- Refactor some date stuff
2018-11-12 20:38:00 -08:00
James Hillyerd
d05eb10851 ui: Friendly date format for mailbox list, monitor 2018-11-12 20:38:00 -08:00
James Hillyerd
9e2f138279 ui: Add purge button to Mailbox 2018-11-12 20:38:00 -08:00
James Hillyerd
af9c735cd7 ui: messageList becomes viewMessageList 2018-11-12 20:38:00 -08:00
James Hillyerd
5328406533 ui: Move selected MessageID into MessageList record 2018-11-12 20:38:00 -08:00
James Hillyerd
54ca36c442 ui: Refactor Mailbox states to reduce cases. 2018-11-12 20:38:00 -08:00
James Hillyerd
5ab273b7b8 travis: Remove Go 1.10.x 2018-11-12 20:38:00 -08:00
James Hillyerd
352e8c396d ui: Refactor Mailbox update function 2018-11-12 20:38:00 -08:00
James Hillyerd
f0b4dda8e6 ui: Add search to Mailbox 2018-11-12 20:38:00 -08:00
James Hillyerd
c8dabf8593 ui: Mailbox headers get a MessageList type for search 2018-11-12 20:38:00 -08:00
James Hillyerd
852b9fce26 ui: Refactor mailbox header list into State 2018-11-12 20:38:00 -08:00
James Hillyerd
a8795f46dc ui: Adding Transistioning state to Mailbox to stop flicker 2018-11-12 20:38:00 -08:00
James Hillyerd
bcf0cafb34 ui: Refactor Mailbox model states 2018-11-12 20:38:00 -08:00
James Hillyerd
04a3f58e6d ui: Refactor message/markSeenAt into visible record. 2018-11-12 20:38:00 -08:00
James Hillyerd
7dade7f0e4 ui: Initial impl of seen message marking 2018-11-12 20:38:00 -08:00
James Hillyerd
523c04a522 Logging improvements, handler extraction.
- rest: improve error logging.
- web: extract handlers/middleware into their own file.
- web: log all requests, not just ones hitting our handlers.
- test: improve integration test logging format.
2018-11-12 20:38:00 -08:00
James Hillyerd
7a5459ce08 test: Fix integation test server startup 2018-11-12 20:38:00 -08:00
James Hillyerd
dd14fb9989 ui: Much elm work, such wow
- ui: Fix favicon
- webui: Changes to support serving Elm UI
- Static files now served from `/` mount point.
- Old UI handlers moved to `/serve` mount point, some will still be
  needed by the Elm UI; safe HTML and attachments for example.
- Update dev-start.sh for new UI, with tip on how to build it.
- ui: Detect browser host:port for websocket URL,
- webui: Remove unused mailbox handlers, rename routes
- Many routes not needed by Elm UI.
- `/serve/mailbox/*` becomes `/serve/m/*`.
- webui: Impl custom JSON message API for web UI,
- ui: Refactor Mailbox view functions,
- ui: Add body tabs for safe HTML and plain text,
- webui: Format plain text for new UI,
- ui: List attachments with view & download links,
2018-11-12 20:34:51 -08:00
James Hillyerd
c5b5321be3 ui: Initial Elm UI import
Merged from https://github.com/jhillyerd/inbucket-elm

Uses https://github.com/halfzebra/create-elm-app
2018-11-12 20:34:35 -08:00
James Hillyerd
8b5a05eb40 ui: remove old jquery/HTML template based UI 2018-11-12 20:34:27 -08:00
James Hillyerd
60db73b813 test: Correctly shutdown integration test server 2018-11-03 18:53:24 -07:00
James Hillyerd
ef633b906c travis: remove branch master check for deploy 2018-10-31 20:51:33 -07:00
James Hillyerd
2e49b591eb Merge tag 'v2.1.0-beta1' into develop
v2.1.0-beta1
2018-10-31 20:08:15 -07:00
826 changed files with 16123 additions and 284434 deletions

View File

@@ -6,3 +6,8 @@ inbucket
inbucket.exe
swaks-tests
target
tags
tags.*
ui/dist
ui/elm-stuff
ui/node_modules

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use nix

5
.gitattributes vendored
View File

@@ -1,6 +1,7 @@
# Auto detect text files and perform LF normalization
* text=auto
*.raw -text
* text=auto
*.golden -text
*.raw -text
# Custom for Visual Studio
*.cs diff=csharp

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

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

88
.github/workflows/build-and-test.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Build and Test
on:
push:
branches:
- main
pull_request:
jobs:
linux-go-build:
runs-on: ubuntu-latest
name: Linux Go ${{ matrix.go }} build
strategy:
matrix:
go:
- '1.25'
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Go
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:
path-to-profile: profile.cov
flag-name: Linux-Go-${{ matrix.go }}
parallel: true
windows-go-build:
runs-on: windows-latest
name: Windows Go build
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
with:
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:
path-to-profile: profile.cov
flag-name: Windows-Go
parallel: true
ui-build:
runs-on: ubuntu-latest
name: UI Build
steps:
- uses: actions/checkout@v5
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:
parallel-finished: true

71
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
pull_request_review:
types:
- submitted
workflow_dispatch: # allow for manual run
env:
REGISTRY_PUSH: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
jobs:
build:
name: 'Build Container'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
inbucket/inbucket
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha
type=edge,branch=main
flavor: |
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64, linux/arm/v7
push: ${{ env.REGISTRY_PUSH == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

17
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Lint Go Code
on:
push:
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: latest

55
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Build and Release
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
jobs:
release:
name: 'Go Releaser'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25'
check-latest: true
- 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
- name: Test build release
uses: goreleaser/goreleaser-action@v6
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
distribution: goreleaser
version: "~> v2"
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v6
if: "startsWith(github.ref, 'refs/tags/v')"
with:
distribution: goreleaser
version: "~> v2"
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

34
.gitignore vendored
View File

@@ -3,6 +3,9 @@
*.a
*.so
# Emacs messiness.
*~
# Folders
_obj
_test
@@ -21,11 +24,18 @@ _testmain.go
*.exe
# vim swp files
# vim files
*.swp
*.swo
tags
tags.*
# our binaries
# Desktop Services Store on macOS
.DS_Store
/.direnv
# Inbucket binaries
/client
/client.exe
/inbucket
@@ -35,3 +45,23 @@ _testmain.go
/cmd/client/client.exe
/cmd/inbucket/inbucket
/cmd/inbucket/inbucket.exe
# Elm UI
# elm-package generated files
/ui/index.html
/ui/elm-stuff
/ui/tests/elm-stuff
# elm-repl generated files
repl-temp-*
# Distribution
/ui/dist/
# Dependency directories
/ui/node_modules
/ui/.parcel-cache
# Test lua files
/inbucket.lua
# IntelliJ
.idea
inbucket.iml

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,19 +1,21 @@
version: 2 # goreleaser version
project_name: inbucket
release:
github:
owner: jhillyerd
owner: inbucket
name: inbucket
name_template: '{{.Tag}}'
brew:
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: bin.install ""
before:
hooks:
- go mod download
builds:
- binary: inbucket
- id: inbucket
binary: inbucket
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -21,11 +23,16 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: inbucket-client
- id: inbucket-client
binary: inbucket-client
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -33,48 +40,48 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
main: ./cmd/client
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archive:
format: tar.gz
wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides:
- goos: windows
format: zip
files:
- LICENSE*
- README*
- CHANGELOG*
- etc/**/*
- ui/**/*
archives:
- id: tarball
formats: tar.gz
wrap_in_directory: true
format_overrides:
- goos: windows
formats: zip
files:
- LICENSE*
- README*
- CHANGELOG*
- etc/**
- ui/dist/**
- ui/greeting.html
nfpm:
vendor: inbucket.org
homepage: https://www.inbucket.org/
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
formats:
- deb
- rpm
files:
"ui/**/*": "/usr/local/share/inbucket/ui"
config_files:
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
"ui/greeting.html": "/etc/inbucket/greeting.html"
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
nfpms:
- formats:
- deb
- rpm
vendor: inbucket.org
homepage: https://www.inbucket.org/
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
contents:
- src: "ui/dist/**"
dst: "/usr/share/inbucket/ui"
- src: "etc/linux/inbucket.service"
dst: "/lib/systemd/system/inbucket.service"
type: config|noreplace
- src: "ui/greeting.html"
dst: "/etc/inbucket/greeting.html"
type: config|noreplace
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
sign:
artifacts: none

9
.luarc.json Normal file
View File

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

View File

@@ -1,26 +0,0 @@
language: go
sudo: false
addons:
apt:
packages:
- rpm
env:
global:
- GO111MODULE=on
- DEPLOY_WITH_MAJOR="1.11"
before_script:
- go get golang.org/x/lint/golint
- make deps
go:
- "1.10.x"
- "1.11.x"
deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true

View File

@@ -4,8 +4,208 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v2.1.0-beta1]
## [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
- Reject mail by origin domain: `INBUCKET_SMTP_REJECTORIGINDOMAINS` (#375)
- Wildcard support (#412)
- Version flag for `inbucket` cmd (#385)
- STLS support for POP3 (#384)
- ForceTLS flag for SMTP (#402)
- Lua scripting additions:
- `logger` API for Lua (#407)
- `before.message_stored` handler (#417, #418)
- `$` is replaced with `:` in filestore paths, for `D:\...` syntax (#449)
- REST Client `transport` support (#463)
### Fixed
- UI & Storage paths in systemd service file (#393)
- Web UI will redirect from `prefix` to `prefix/` (#397)
- Include inlines when listing attachments (#398)
- Fail Inbucket startup if unable to create storage dir (#448)
- Close directory file handles immediately, fixes Windows locking (#457)
## [v3.1.0-beta1] - 2023-02-28
### Added
- Monitor tab updates when messages are deleted (#337)
- Initial framework for extensions
- Initial Lua scripting implementation, supporting events:
- `after.message_deleted`
- `after.message_stored`
- `before.mail_accepted`
- Provide `http` and `json` modules for Lua scripts
### Fixed
- Support for IP address as domain in RCPT TO (#285)
## [v3.0.4] - 2022-10-02
### Fixed
- More flexible support of `AUTH=<>` FROM parameter (#291)
## [v3.0.3] - 2022-08-07
### Fixed
- Support for `AUTH=<>` FROM parameter (#284)
## [v3.0.2] - 2022-07-04
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
details.
### Changed
- arm Docker builds now rely on amd64 frontend build stage
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
## [v3.0.1-rc2] - 2022-01-23
### Added
- Builds for arm7 and arm64 platforms
### Changed
- Abandoned git-flow process, the `master` branch renamed to `main`
## [v3.0.1-rc1] - 2022-01-17
### Fixed
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
### Changed
- Update Go dependencies
- Update NPM dependencies
## [v3.0.0] - 2021-09-19
Unchanged from rc4.
## [v3.0.0-rc4] - 2021-08-22
### Fixed
- Various MIME header decoding improvements
### Changed
- Bump Go version to 1.17 (#233)
## v3.0.0-rc3 - 2021-08-01
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
tags for Docker Hub and ghcr.io.
## [v3.0.0-rc2] - 2021-07-31
### Added
- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)
### Fixed
- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
## [v3.0.0-rc1] - 2020-09-24
### Added
- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights
### Changed
- The UI now includes the Open Sans webfont instead of relying on browser/OS
fonts
## [v3.0.0-beta3] - 2020-09-04
### Added
- Docker `HEALTHCHECK`
- Mouse-out delay to improve pop-up menu navigation
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
### Changed
- Updated frontend and backend dependencies, Docker image base
### Fixed
- Improved layout on mobile and wide displays
- Prevent unexpected input for modal dialogs
- Allow empty SMTP `MAIL FROM:<>`
## [v3.0.0-beta2] - 2019-08-17
### Added
- Ability to name mailboxes after domain of email recipient, set via
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
### Changed
- Updated JavaScript dependencies.
- Updated Go dependencies.
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
### Fixed
- URLs to view/download attachments from REST API, #138
- Support for late EHLO, #141
## [v3.0.0-beta1] - 2019-03-14
### Added
- `posix-millis` field to REST message and header responses for easier date
parsing.
### Changed
- Rewrote the user interface from scratch, it's now an Elm powered single page
application.
- Moved the Inbucket repository to its own GitHub organization.
- Update to enmime v0.5.0
## v2.1.0 - 2018-12-15
No change from beta1.
## [v2.1.0-beta1] - 2018-10-31
### Added
- Use Go 1.11 modules for reproducible builds.
@@ -98,7 +298,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
provides a more natural API
- Powerful command line REST
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
@@ -113,9 +313,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
- Attachment list to [GET message
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
@@ -178,33 +378,52 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0...main
[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
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
## Release Checklist
1. Create release branch: `git flow release start 1.x.0`
1. Create a release branch
2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Rename *Unreleased* section to release name and date
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
3. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
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
-- it will add compiled release assets
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -1,11 +1,8 @@
How to Contribute
=================
# How to Contribute
Inbucket encourages third-party patches. It's valuable to know how other
developers are using the product.
**tl;dr:** File pull requests against the `develop` branch, not `master`!
## Getting Started
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
## Making Changes
Inbucket uses [git-flow] with default options. If you have git-flow installed,
you can run `git flow feature start <topic branch name>`.
Without git-flow, create a topic branch from where you want to base your work:
- This is usually the `develop` branch, example command:
`git checkout origin/develop -b <topic branch name>`
- Only target the `master` branch if the issue is already resolved in
`develop`.
Inbucket follows the regular GitHub pattern. Create a topic branch from where
you want to base your work:
Once you are on your topic branch:
1. Make commits of logical units.
2. Add unit tests to exercise your changes.
3. Run the updated code through `go fmt` and `go vet`.
4. Ensure the code builds and tests with the following commands:
- `go clean ./...`
- `go build ./...`
- `go test ./...`
3. Run `make` to test, vet and confirm your code is formatted correctly.
If you do not have Make installed, please perform these steps manually,
otherwise your PR will not pass our checks.
## Thanks
Thank you for contributing to Inbucket!
[git-flow]: https://github.com/nvie/gitflow

View File

@@ -1,35 +1,51 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Build
FROM golang:1.11-alpine3.8 as builder
RUN apk add --no-cache --virtual .build-deps git make
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 AS frontend
RUN npm install -g node-gyp
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
WORKDIR /build/ui
RUN rm -rf .parcel-cache dist elm-stuff node_modules
RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.25-alpine3.22 AS backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
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.8
### Run in minimal image
FROM alpine:3.22
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin
COPY --from=backend /build/inbucket bin
COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY ui ui
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_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'
# Ports: SMTP, HTTP, POP3
EXPOSE 2500 9000 1100

View File

@@ -9,7 +9,7 @@ commands = client inbucket
all: clean test lint build
$(commands): %: cmd/%
$(commands): %: cmd/% $(SRC)
go build ./$<
clean:
@@ -32,8 +32,11 @@ simplify:
@gofmt -s -l -w $(SRC)
lint:
@echo "gofmt check..."
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
@echo "golint check..."
@golint -set_exit_status $(PKGS)
@echo "go vet check..."
@go vet $(PKGS)
reflex:

View File

@@ -1,19 +1,21 @@
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
![Build Status](https://github.com/inbucket/inbucket/actions/workflows/build-and-test.yml/badge.svg)
![Docker Image](https://github.com/inbucket/inbucket/actions/workflows/docker-build.yml/badge.svg)
# Inbucket
Inbucket is an email testing service; it will accept messages for any email
address and make them available via web, REST and POP3. Once compiled,
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
address and make them available via web, REST and POP3 interfaces. Once
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
and storage are all built in.
A Go client for the REST API is available in
`github.com/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
Read more at the [Inbucket Website]
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
## Development Status
Inbucket is currently production quality: it is being used for real work.
@@ -22,49 +24,73 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
to contribute code to the project check out [CONTRIBUTING.md].
## Homebrew Tap
## Docker
(currently broken, being tracked in [issue
#68](https://github.com/jhillyerd/inbucket/issues/68))
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
tracks our tagged releases, and `edge` tracks our potentially unstable
`main` branch.
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
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
You will need a functioning [Go installation][Google Go] for this to work.
You will need functioning [Go] and [Node.js] installations for this to work.
Grab the Inbucket source code and compile the daemon:
```sh
git clone https://github.com/inbucket/inbucket.git
cd inbucket/ui
yarn install
yarn build
cd ..
go build ./cmd/inbucket
```
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
For more information on building and development flows, check out the
[Development Quickstart] page of our wiki.
Edit etc/inbucket.conf and tailor to your environment. It should work on most
Unix and OS X machines as is. Launch the daemon:
### Configure and Launch
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
Inbucket reads its configuration from environment variables, but comes with
reasonable defaults built-in. It should work on most Unix and OS X machines as
is. Launch the daemon:
```sh
./inbucket
```
By default the SMTP server will be listening on localhost port 2500 and
the web interface will be available at [localhost:9000](http://localhost:9000/).
The Inbucket website has a more complete guide to
[installing from source][From Source]
See doc/[config.md] for more information on configuring Inbucket, but you will
likely find the [Configurator] tool the easiest way to generate a configuration.
## About
Inbucket is written in [Google Go]
Inbucket is written in [Go] and [Elm].
Inbucket is open source software released under the MIT License. The latest
version can be found at https://github.com/jhillyerd/inbucket
version can be found at https://github.com/inbucket/inbucket
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
[From Source]: http://www.inbucket.org/installation/from-source.html
[Google Go]: http://golang.org/
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
[Inbucket Website]: http://www.inbucket.org/
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
[Build Status]: https://travis-ci.org/inbucket/inbucket
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
[Configurator]: https://www.inbucket.org/configurator/
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
[Docker Image]: https://inbucket.org/packages/docker.html
[Elm]: https://elm-lang.org/
[From Source]: https://www.inbucket.org/installation/from-source.html
[Go]: https://golang.org/
[Go API docs]: https://pkg.go.dev/github.com/inbucket/inbucket/pkg/rest/client
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
[Inbucket Website]: https://www.inbucket.org/
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
[Node.js]: https://nodejs.org/en/

View File

@@ -6,12 +6,10 @@ import (
"fmt"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
type listCmd struct {
mailbox string
}
type listCmd struct{}
func (*listCmd) Name() string {
return "list"
@@ -27,22 +25,23 @@ func (*listCmd) Usage() string {
`
}
func (l *listCmd) SetFlags(f *flag.FlagSet) {
}
func (l *listCmd) SetFlags(f *flag.FlagSet) {}
func (l *listCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup rest client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("REST call failed", err)
}

View File

@@ -5,8 +5,10 @@ import (
"context"
"flag"
"fmt"
"net"
"os"
"regexp"
"strconv"
"github.com/google/subcommands"
)
@@ -50,14 +52,17 @@ func main() {
// Important top-level flags
subcommands.ImportantFlag("host")
subcommands.ImportantFlag("port")
// Setup standard helpers
subcommands.Register(subcommands.HelpCommand(), "")
subcommands.Register(subcommands.FlagsCommand(), "")
subcommands.Register(subcommands.CommandsCommand(), "")
// Setup my commands
subcommands.Register(&listCmd{}, "")
subcommands.Register(&matchCmd{}, "")
subcommands.Register(&mboxCmd{}, "")
// Parse and execute
flag.Parse()
ctx := context.Background()
@@ -65,7 +70,7 @@ func main() {
}
func baseURL() string {
return fmt.Sprintf("http://%s:%v", *host, *port)
return "http://%s" + net.JoinHostPort(*host, strconv.FormatUint(uint64(*port), 10))
}
func fatal(msg string, err error) subcommands.ExitStatus {

View File

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

View File

@@ -7,12 +7,11 @@ import (
"os"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
type mboxCmd struct {
mailbox string
delete bool
delete bool
}
func (*mboxCmd) Name() string {
@@ -34,48 +33,55 @@ func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
}
func (m *mboxCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
err = outputMbox(headers)
err = outputMbox(ctx, headers)
if err != nil {
return fatal("Error", err)
}
// Optionally, delete retrieved messages
if m.delete {
// Delete matches
for _, h := range headers {
err = h.Delete()
err = h.DeleteWithContext(ctx)
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
// outputMbox renders messages in mbox format
// also used by match subcommand
func outputMbox(headers []*client.MessageHeader) error {
// outputMbox renders messages in mbox format.
// It is also used by match subcommand.
func outputMbox(ctx context.Context, headers []*client.MessageHeader) error {
for _, h := range headers {
source, err := h.GetSource()
source, err := h.GetSourceWithContext(ctx)
if err != nil {
return fmt.Errorf("Get source REST failed: %v", err)
return fmt.Errorf("get source REST failed: %v", err)
}
fmt.Printf("From %s\n", h.From)
// TODO Escape "From " in message bodies with >
source.WriteTo(os.Stdout)
if _, err := source.WriteTo(os.Stdout); err != nil {
return err
}
fmt.Println()
}
return nil

View File

@@ -14,18 +14,11 @@ import (
"syscall"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/server/pop3"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/file"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/server"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage/file"
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -40,13 +33,11 @@ var (
func init() {
// Server uptime for status page.
startTime := time.Now()
expvar.Publish("uptime", expvar.Func(func() interface{} {
return time.Since(startTime) / time.Second
}))
startTime := expvar.NewInt("startMillis")
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()
}))
@@ -58,6 +49,7 @@ func init() {
func main() {
// Command line flags.
help := flag.Bool("help", false, "Displays help on flags and env variables.")
versionflag := flag.Bool("version", false, "Displays version.")
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
@@ -73,6 +65,11 @@ func main() {
config.Usage()
return
}
if *versionflag {
fmt.Fprintln(os.Stdout, version)
return
}
// Process configuration.
config.Version = version
config.BuildDate = date
@@ -85,6 +82,7 @@ func main() {
conf.POP3.Debug = true
conf.SMTP.Debug = true
}
// Logger setup.
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
if err != nil {
@@ -92,12 +90,15 @@ func main() {
os.Exit(1)
}
startupLog := log.With().Str("phase", "startup").Logger()
// Setup signal handler.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging.
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
Msg("Inbucket starting")
// Write pidfile if requested.
if *pidfile != "" {
pidf, err := os.Create(*pidfile)
@@ -109,31 +110,18 @@ func main() {
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
}
}
// Configure internal services.
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
// Configure and start internal services.
svcCtx, svcCancel := context.WithCancel(context.Background())
services, err := server.FullAssembly(conf)
if err != nil {
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
removePIDFile(*pidfile)
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
}
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub)
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
services.Start(svcCtx, func() {
startupLog.Debug().Msg("All services report ready")
})
// Loop forever waiting for signals or shutdown channel.
signalLoop:
for {
@@ -144,29 +132,37 @@ signalLoop:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
Msg("Received SIGINT, shutting down")
close(shutdownChan)
svcCancel()
break signalLoop
case syscall.SIGTERM:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
Msg("Received SIGTERM, shutting down")
close(shutdownChan)
svcCancel()
break signalLoop
}
case <-shutdownChan:
rootCancel()
case <-services.Notify():
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
svcCancel()
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
smtpServer.Drain()
pop3Server.Drain()
retentionScanner.Join()
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
services.SMTPServer.Drain()
log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
services.POP3Server.Drain()
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
services.RetentionScanner.Join()
removePIDFile(*pidfile)
closeLog()
}
// openLog configures zerolog output, returns func to close logfile.
func openLog(level string, logfile string, json bool) (close func(), err error) {
func openLog(level string, logfile string, json bool) (closeLog func(), err error) {
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
@@ -177,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 {
@@ -195,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.

View File

@@ -9,7 +9,8 @@ variables it supports:
KEY DEFAULT DESCRIPTION
INBUCKET_LOGLEVEL info debug, info, warn, or error
INBUCKET_MAILBOXNAMING local Use local or full addressing
INBUCKET_LUA_PATH inbucket.lua Lua script path
INBUCKET_MAILBOXNAMING local Use local, full, or domain addressing
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
INBUCKET_SMTP_DOMAIN inbucket HELO domain
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
@@ -17,6 +18,7 @@ variables it supports:
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
INBUCKET_SMTP_REJECTORIGINDOMAINS Domains to reject mail from
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
@@ -28,11 +30,9 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_UIDIR ui User interface dir
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
@@ -58,6 +58,16 @@ off with `warn` or `error`.
- Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error`
### Lua Script
`INBUCKET_LUA_PATH`
This is the path to the (optional) Inbucket Lua script. If the specified file
is present, Inbucket will load it during startup. Ignored if the file is not
found, or the setting is empty.
- Default: `inbucket.lua`
### Mailbox Naming
`INBUCKET_MAILBOXNAMING`
@@ -79,8 +89,14 @@ Prior to the addition of the mailbox naming setting, Inbucket always operated in
local mode. Regardless of this setting, the `+` wildcard/extension is not
incorporated into the mailbox name.
#### `domain` ensures the local-part is removed, such that:
- `james@inbucket.org` is stored in `inbucket.org`
- `matt@inbucket.org` is stored in `inbucket.org`
- `matt@noinbucket.com` is stored in `notinbucket.com`
- Default: `local`
- Values: one of `local` or `full`
- Values: one of `local` or `full` or `domain`
## SMTP
@@ -146,7 +162,7 @@ List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `localhost,mysite.org`
### Rejected Recipient Domain List
@@ -157,7 +173,22 @@ List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
has no effect when false.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `reject.com,gmail.com`
### Rejected Origin Domain List
`INBUCKET_SMTP_REJECTORIGINDOMAINS`
List of domains to reject mail from. This list is enforced regardless of the
`INBUCKET_SMTP_DEFAULTACCEPT` value.
Enforcement takes place during evalation of the `MAIL FROM` SMTP command, the
origin domain is extracted from the address presented and compared against the
list. It does not take email headers into account.
- Default: None
- Values: Comma separated list of origin domains
- Example: `reject.com,gmail.com`
### Default Recipient Store Policy
@@ -179,7 +210,7 @@ List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `localhost,mysite.org`
### Discarded Recipient Domain List
@@ -192,7 +223,7 @@ emails. Messages sent to a domain other than this will be stored normally.
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `recycle.com,loadtest.org`
### Network Idle Timeout
@@ -228,7 +259,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
### TLS Public Certificate File
`INBUCKET_SMTP_TLSPRIVKEY`
`INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
@@ -287,6 +318,24 @@ Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:9000`
### Base Path
`INBUCKET_WEB_BASEPATH`
Base path prefix for UI and API URLs. This option is used when you wish to
root all Inbucket URLs to a specific path when placing it behind a
reverse-proxy.
For example, setting the base path to `prefix` will move:
- the Inbucket status page from `/status` to `/prefix/status`,
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
*Note:* This setting will not work correctly when running Inbucket via the npm
development server.
- Default: None
### UI Directory
`INBUCKET_WEB_UIDIR`
@@ -298,7 +347,7 @@ doesn't contain the `ui` directory at startup.
Inbucket will load templates from the `templates` sub-directory, and serve
static assets from the `static` sub-directory.
- Default: `ui`
- Default: `ui/dist`
- Values: Operating system specific path syntax
### Greeting HTML File
@@ -311,39 +360,6 @@ Inbucket installation, as well as link to REST documentation, etc.
- Default: `ui/greeting.html`
### Template Caching
`INBUCKET_WEB_TEMPLATECACHE`
Tells Inbucket to cache parsed template files. This should be left as default
unless you are a developer working on the Inbucket web interface.
- Default: `true`
- Values: `true` or `false`
### Mailbox Prompt
`INBUCKET_WEB_MAILBOXPROMPT`
Text prompt displayed to the right of the mailbox name input field in the web
interface. Can be used to nudge your users into typing just the mailbox name
instead of an entire email address.
Set to an empty string to hide the prompt.
- Default: `@inbucket`
### Cookie Authentication Key
`INBUCKET_WEB_COOKIEAUTHKEY`
Inbucket stores session information in an encrypted browser cookie. Unless
specified, Inbucket generates a random key at startup. The only notable data
stored in a user session is the list of recently accessed mailboxes.
- Default: None
- Value: Text string, no particular format required
### Monitor Visible
`INBUCKET_WEB_MONITORVISIBLE`
@@ -426,7 +442,8 @@ separated list of key:value pairs.
#### `file` type parameters
- `path`: Operating system specific path to the directory where mail should be
stored.
stored. `$` characters will be replaced with `:` in the final path value,
allowing Windows drive letters, i.e. `D$\inbucket`.
#### `memory` type parameters

View File

@@ -3,16 +3,33 @@
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
#export INBUCKET_MAILBOXNAMING="domain"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
#export INBUCKET_SMTP_DEFAULTSTORE="false"
export INBUCKET_SMTP_STOREDOMAINS="important.local"
export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false"
#export INBUCKET_WEB_BASEPATH="prefix"
export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2
echo "Run this script from the inbucket root directory after running make" >&2
echo "Run this script from the inbucket root directory after running make." >&2
exit 1
fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'yarn build' from the 'ui' directory." >&2
exit 1
fi

View File

@@ -1,3 +1,5 @@
<h1>Welcome to Inbucket</h1>
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
@@ -11,5 +13,7 @@ of 300 messages per mailbox - the oldest messages will be deleted to stay under
that limit.</p>
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
domain will be accepted but not written to disk. Use this domain for load or
soak testing your application.</p>
domain will be accepted, but immediately <b>discarded</b> without being written
to disk. Use this domain for load or soak testing your application. Inbucket
will retain mail for any other domain by default, i.e.
<code>@inbucket.local</code>.</p>

View File

@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image
# Docker Image Tag
IMAGE="jhillyerd/inbucket"
IMAGE="inbucket/inbucket:edge"
# Ports exposed on host:
PORT_HTTP=9000
@@ -25,6 +25,9 @@ main() {
usage
exit
;;
-b)
build
;;
-r)
reset
;;
@@ -38,6 +41,8 @@ main() {
esac
done
set -x
docker run $run_opts \
-p $PORT_HTTP:9000 \
-p $PORT_SMTP:2500 \
@@ -49,14 +54,21 @@ main() {
usage() {
echo "$0 [options]" 2>&1
echo " -b build - build image before starting" 2>&1
echo " -d detach - detach and print container ID" 2>&1
echo " -r reset - purge config and data before startup" 2>&1
echo " -h help - print this message" 2>&1
}
build() {
echo "Building $IMAGE"
docker build . -t "$IMAGE"
echo
}
reset() {
/bin/rm -rf "$VOL_CONFIG"
/bin/rm -rf "$VOL_DATA"
rm -rf "$VOL_CONFIG"
rm -rf "$VOL_DATA"
}
main $*

View File

@@ -12,18 +12,18 @@ Environment=INBUCKET_LOGLEVEL=warn
Environment=INBUCKET_SMTP_ADDR=0.0.0.0:2500
Environment=INBUCKET_POP3_ADDR=0.0.0.0:1100
Environment=INBUCKET_WEB_ADDR=0.0.0.0:9000
Environment=INBUCKET_WEB_UIDIR=/usr/local/share/inbucket/ui
Environment=INBUCKET_WEB_UIDIR=/usr/share/inbucket/ui
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
Environment=INBUCKET_STORAGE_TYPE=file
Environment=INBUCKET_STORAGE_PARAMS=path:/var/local/inbucket
Environment=INBUCKET_STORAGE_PARAMS=path:/var/inbucket
# Uncomment line below to use low numbered ports
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbucket
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/bin/inbucket
ExecStartPre=/bin/mkdir -p /var/local/inbucket
ExecStartPre=/bin/chown daemon:daemon /var/local/inbucket
ExecStartPre=/bin/mkdir -p /var/inbucket
ExecStartPre=/bin/chown daemon:daemon /var/inbucket
ExecStart=/usr/local/bin/inbucket
ExecStart=/usr/bin/inbucket
# Give SMTP connections time to drain
TimeoutStopSec=20

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# rest-apiv1.sh
# description: Script to access Inbucket REST API version 1

View File

@@ -0,0 +1,31 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: MIME Errors
Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
Content-Type: multipart/alternative; boundary="Enmime-Test-100"
--Enmime-Test-100
Content-Transfer-Encoding: 8bit
Content-Type: text/plain; charset=us-ascii
Using Unicode/UTF-8, you can write in emails and source code things such as
Mathematics and sciences:
∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ⎧⎡⎛┌─────┐⎞⎤⎫
⎪⎢⎜│a²+b³ ⎟⎥⎪
∀x∈: ⌈x⌉ = x⌋, α ∧ ¬β = ¬(¬α β), ⎪⎢⎜│───── ⎟⎥⎪
⎪⎢⎜⎷ c₈ ⎟⎥⎪
⊆ ℕ₀ ⊂ , ⎨⎢⎜ ⎟⎥⎬
⎪⎢⎜ ∞ ⎟⎥⎪
⊥ < a ≠ b ≡ c ≤ d ≪ ⇒ (⟦A⟧ ⇔ ⟪B⟫), ⎪⎢⎜ ⎲ ⎟⎥⎪
⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm ⎩⎣⎝i=1 ⎠⎦⎭
Linguistics and dictionaries:
ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
--Enmime-Test-100

View File

@@ -0,0 +1,43 @@
Subject: Inline attachment
From: %FROM_ADDRESS%
To: %TO_ADDRESS%
Message-ID: <1234@example.com>
Date: %DATE%
Content-Type: multipart/mixed; boundary=boundary1
--boundary1
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE html>
<html>
<head>
<title>Hello World HTML</title>
</head>
<body>
<h1 style=3D"color:red">Hello World</h1>
</body>
</html>
--boundary1
Content-Type: application/pdf; name=Hello-World.pdf
Content-Transfer-Encoding: base64
Content-Disposition: inline; name=Hello-World.pdf;
filename=Hello-World.pdf
JVBERi0xLjQKJcK1wrYKCjEgMCBvYmoKPDwvVGl0bGUoSGVsbG8gV29ybGQpL0F1dGhvcihBZHJp
dW0pPj4KZW5kb2JqCgoyIDAgb2JqCjw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAzIDAgUj4+CmVuZG9i
agoKMyAwIG9iago8PC9UeXBlL1BhZ2VzL01lZGlhQm94WzAgMCA1OTUgODQyXS9SZXNvdXJjZXM8
PC9Gb250PDwvRjEgNCAwIFI+Pi9Qcm9jU2V0Wy9QREYvVGV4dF0+Pi9LaWRzWzUgMCBSXS9Db3Vu
dCAxPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250
L0hlbHZldGljYS9FbmNvZGluZy9XaW5BbnNpRW5jb2Rpbmc+PgplbmRvYmoKCjUgMCBvYmoKPDwv
VHlwZS9QYWdlL1BhcmVudCAzIDAgUi9Db250ZW50cyA2IDAgUj4+CmVuZG9iagoKNiAwIG9iago8
PC9MZW5ndGggNTEvRmlsdGVyL0ZsYXRlRGVjb2RlPj4Kc3RyZWFtCnic03czVDCxUAhJ43IK4TI3
UjA3MVMISeHS8EjNyclXCM8vyknRVAjJ4nIN4QIA3FcKuwplbmRzdHJlYW0KZW5kb2JqCgp4cmVm
CjAgNwowMDAwMDAwMDAwIDY1NTM2IGYgCjAwMDAwMDAwMTYgMDAwMDAgbiAKMDAwMDAwMDA3MSAw
MDAwMCBuIAowMDAwMDAwMTE3IDAwMDAwIG4gCjAwMDAwMDAyNDIgMDAwMDAgbiAKMDAwMDAwMDMz
MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCgp0cmFpbGVyCjw8L1NpemUgNy9JbmZvIDEg
MCBSL1Jvb3QgMiAwIFI+PgpzdGFydHhyZWYKNTA5CiUlRU9GCg==
--boundary1--

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# run-tests.sh
# description: Generate test emails for Inbucket
@@ -24,7 +24,7 @@ case "$1" in
;;
esac
export SWAKS_OPT_server="127.0.0.1:2500"
export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
export SWAKS_OPT_to="$to@inbucket.local"
# Basic test
@@ -56,3 +56,13 @@ swaks $* --data outlook.raw
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw
# IP RCPT domain
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body text.txt
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body text.txt
# Inline attachment test
swaks $* --data mime-inline.raw

View File

@@ -1,10 +0,0 @@
#!/bin/bash
# travis-deploy.sh
# description: Trigger goreleaser deployment in correct build scenarios
set -eo pipefail
set -x
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
curl -sL https://git.io/goreleaser | bash
fi

55
go.mod
View File

@@ -1,19 +1,44 @@
module github.com/jhillyerd/inbucket
module github.com/inbucket/inbucket/v3
go 1.25.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.6.2
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.1.3
github.com/gorilla/websocket v1.4.0
github.com/jhillyerd/enmime v0.2.1
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
github.com/cosmotek/loguago v1.0.0
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/inbucket/gopher-json v0.2.0
github.com/jhillyerd/enmime/v2 v2.1.0
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.3.0
github.com/microcosm-cc/bluemonday v1.0.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.9.1
github.com/stretchr/testify v1.2.2
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
github.com/kelseyhightower/envconfig v1.4.0
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.40.0
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/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.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.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.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
)

119
go.sum
View File

@@ -1,42 +1,93 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cosmotek/loguago v1.0.0 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
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=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.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.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/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.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/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.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=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/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.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.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.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.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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -8,7 +8,7 @@ import (
"text/tabwriter"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
@@ -38,6 +38,7 @@ const (
UnknownNaming mbNaming = iota
LocalNaming
FullNaming
DomainNaming
)
// Decode a naming strategy from string.
@@ -47,57 +48,69 @@ func (n *mbNaming) Decode(v string) error {
*n = LocalNaming
case "full":
*n = FullNaming
case "domain":
*n = DomainNaming
default:
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
return fmt.Errorf("unknown MailboxNaming strategy: %q", v)
}
return nil
}
// Root contains global configuration, and structs with for specific sub-systems.
type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
Lua Lua
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
SMTP SMTP
POP3 POP3
Web Web
Storage Storage
}
// Lua contains the Lua extension host configuration.
type Lua struct {
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
}
// SMTP contains the SMTP server configuration.
type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
RejectOriginDomains []string `desc:"Domains to reject mail from"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
ForceTLS bool `default:"false" desc:"Listen for connections with TLS."`
}
// POP3 contains the POP3 server configuration.
type POP3 struct {
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
TLSEnabled bool `default:"false" desc:"Enable TLS"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
}
// Web contains the HTTP server configuration.
type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
CookieAuthKey string `desc:"Session cipher key (text)"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
@@ -121,6 +134,7 @@ func Process() (*Root, error) {
stringutil.SliceToLower(c.SMTP.RejectDomains)
stringutil.SliceToLower(c.SMTP.StoreDomains)
stringutil.SliceToLower(c.SMTP.DiscardDomains)
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
return c, err
}

View File

@@ -0,0 +1,89 @@
package extension
import (
"errors"
"sync"
"time"
)
// AsyncEventBroker maintains a list of listeners interested in a specific type
// of event. Events are sent in parallel to all listeners, and no result is
// returned.
type AsyncEventBroker[E any] struct {
sync.RWMutex
listenerNames []string // Ordered listener names.
listenerFuncs []func(E) // Ordered listener functions.
}
// Emit sends the provided event to each registered listener in parallel.
func (eb *AsyncEventBroker[E]) Emit(event *E) {
eb.RLock()
defer eb.RUnlock()
for _, l := range eb.listenerFuncs {
// Events are copied to minimize the risk of mutation.
go l(*event)
}
}
// AddListener registers the named listener, replacing one with a duplicate
// name if present. Listeners should be added in order of priority, most
// significant first.
func (eb *AsyncEventBroker[E]) AddListener(name string, listener func(E)) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
eb.listenerNames = append(eb.listenerNames, name)
eb.listenerFuncs = append(eb.listenerFuncs, listener)
}
// RemoveListener unregisters the named listener.
func (eb *AsyncEventBroker[E]) RemoveListener(name string) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
}
func (eb *AsyncEventBroker[E]) lockedRemoveListener(name string) {
for i, entry := range eb.listenerNames {
if entry == name {
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
break
}
}
}
// AsyncTestListener returns a func that will wait for an event and return it, or timeout
// with an error.
func (eb *AsyncEventBroker[E]) AsyncTestListener(name string, capacity int) func() (*E, error) {
// Send event down channel.
events := make(chan E, capacity)
eb.AddListener(name,
func(msg E) {
events <- msg
})
count := 0
return func() (*E, error) {
count++
defer func() {
if count >= capacity {
eb.RemoveListener(name)
close(events)
}
}()
select {
case event := <-events:
return &event, nil
case <-time.After(time.Second * 2):
return nil, errors.New("timeout waiting for event")
}
}
}

View File

@@ -0,0 +1,101 @@
package extension_test
import (
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Simple smoke test without using AsyncTestListener.
func TestAsyncBrokerEmitCallsOneListener(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listener.
events := make(chan string, 1)
listener := func(s string) {
events <- s
}
broker.AddListener("x", listener)
want := "bacon"
broker.Emit(&want)
var got string
select {
case event := <-events:
got = event
case <-time.After(time.Second * 2):
t.Fatal("Timeout waiting for event")
}
if got != want {
t.Errorf("Emit got %q, want %q", got, want)
}
}
func TestAsyncBrokerEmitCallsMultipleListeners(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("first", 1)
second := broker.AsyncTestListener("second", 1)
want := "hi"
broker.Emit(&want)
firstGot, err := first()
require.NoError(t, err)
assert.Equal(t, want, *firstGot)
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("dup", 1)
second := broker.AsyncTestListener("dup", 1)
want := "hi"
broker.Emit(&want)
firstGot, err := first()
require.Error(t, err)
assert.Nil(t, firstGot)
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
// Setup listeners.
first := broker.AsyncTestListener("1", 1)
second := broker.AsyncTestListener("2", 1)
broker.RemoveListener("1")
want := "hi"
broker.Emit(&want)
firstGot, err := first()
require.Error(t, err)
assert.Nil(t, firstGot)
secondGot, err := second()
require.NoError(t, err)
assert.Equal(t, want, *secondGot)
}
func TestAsyncBrokerRemovingMissingListener(t *testing.T) {
broker := &extension.AsyncEventBroker[string]{}
broker.RemoveListener("doesn't crash")
}

59
pkg/extension/broker.go Normal file
View File

@@ -0,0 +1,59 @@
package extension
import (
"sync"
)
// EventBroker maintains a list of listeners interested in a specific type
// of event.
type EventBroker[E any, R interface{}] struct {
sync.RWMutex
listenerNames []string // Ordered listener names.
listenerFuncs []func(E) *R // Ordered listener functions.
}
// Emit sends the provided event to each registered listener in order, until
// one returns a non-nil result. That result will be returned to the caller.
func (eb *EventBroker[E, R]) Emit(event *E) *R {
eb.RLock()
defer eb.RUnlock()
for _, l := range eb.listenerFuncs {
// Events are copied to minimize the risk of mutation.
if result := l(*event); result != nil {
return result
}
}
return nil
}
// AddListener registers the named listener, replacing one with a duplicate
// name if present. Listeners should be added in order of priority, most
// significant first.
func (eb *EventBroker[E, R]) AddListener(name string, listener func(E) *R) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
eb.listenerNames = append(eb.listenerNames, name)
eb.listenerFuncs = append(eb.listenerFuncs, listener)
}
// RemoveListener unregisters the named listener.
func (eb *EventBroker[E, R]) RemoveListener(name string) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
}
func (eb *EventBroker[E, R]) lockedRemoveListener(name string) {
for i, entry := range eb.listenerNames {
if entry == name {
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
break
}
}
}

View File

@@ -0,0 +1,134 @@
package extension_test
import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension"
)
func TestBrokerEmitCallsOneListener(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listener.
var got string
listener := func(s string) *bool {
got = s
return nil
}
broker.AddListener("x", listener)
want := "bacon"
broker.Emit(&want)
if got != want {
t.Errorf("Emit got %q, want %q", got, want)
}
}
func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var firstGot, secondGot string
first := func(s string) *bool {
firstGot = s
return nil
}
second := func(s string) *bool {
secondGot = s
return nil
}
broker.AddListener("1", first)
broker.AddListener("2", second)
want := "hi"
broker.Emit(&want)
if firstGot != want {
t.Errorf("first got %q, want %q", firstGot, want)
}
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}
func TestBrokerEmitCapturesFirstResult(t *testing.T) {
broker := &extension.EventBroker[struct{}, string]{}
// Setup listeners.
makeListener := func(result *string) func(struct{}) *string {
return func(s struct{}) *string { return result }
}
first := "first"
second := "second"
broker.AddListener("0", makeListener(nil))
broker.AddListener("1", makeListener(&first))
broker.AddListener("2", makeListener(&second))
want := first
got := broker.Emit(&struct{}{})
if got == nil {
t.Errorf("Emit got nil, want %q", want)
} else if *got != want {
t.Errorf("Emit got %q, want %q", *got, want)
}
}
func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var firstGot, secondGot string
first := func(s string) *bool {
firstGot = s
return nil
}
second := func(s string) *bool {
secondGot = s
return nil
}
broker.AddListener("dup", first)
broker.AddListener("dup", second)
want := "hi"
broker.Emit(&want)
if firstGot != "" {
t.Errorf("first got %q, want empty string", firstGot)
}
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}
func TestBrokerRemovingListenerSuccessful(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
// Setup listeners.
var firstGot, secondGot string
first := func(s string) *bool {
firstGot = s
return nil
}
second := func(s string) *bool {
secondGot = s
return nil
}
broker.AddListener("1", first)
broker.AddListener("2", second)
broker.RemoveListener("1")
want := "hi"
broker.Emit(&want)
if firstGot != "" {
t.Errorf("first got %q, want empty string", firstGot)
}
if secondGot != want {
t.Errorf("second got %q, want %q", secondGot, want)
}
}
func TestBrokerRemovingMissingListener(t *testing.T) {
broker := &extension.EventBroker[string, bool]{}
broker.RemoveListener("doesn't crash")
}

View File

@@ -0,0 +1,56 @@
package event
import (
"net/mail"
"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
Domain string
}
// InboundMessage contains the basic header and mailbox data for a message being received.
type InboundMessage struct {
Mailboxes []string
From *mail.Address
To []*mail.Address
Subject string
Size int64
}
// MessageMetadata contains the basic header data for a message event.
type MessageMetadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}
// 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
}

36
pkg/extension/host.go Normal file
View File

@@ -0,0 +1,36 @@
package extension
import (
"github.com/inbucket/inbucket/v3/pkg/extension/event"
)
// Host defines extension points for Inbucket.
type Host struct {
Events *Events
}
// Events defines all the event types supported by the extension host.
//
// Before-events provide an opportunity for extensions to alter how Inbucket responds to that type
// of event. These events are processed synchronously; expensive operations will reduce the
// perceived performance of Inbucket. The first listener in the list to respond with a non-nil
// value will determine the response, and the remaining listeners will not be called.
//
// After-events allow extensions to take an action after an event has completed. These events are
// processed asynchronously with respect to the rest of Inbuckets operation. However, an event
// listener will not be called until the one before it completes.
type Events struct {
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
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.
type Void struct{}
// NewHost creates a new extension host.
func NewHost() *Host {
return &Host{Events: &Events{}}
}

View File

@@ -0,0 +1,92 @@
package luahost
import (
"net/mail"
lua "github.com/yuin/gopher-lua"
)
const mailAddressName = "address"
func registerMailAddressType(ls *lua.LState) {
mt := ls.NewTypeMetatable(mailAddressName)
ls.SetGlobal(mailAddressName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newMailAddress))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(mailAddressIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(mailAddressNewIndex))
}
func newMailAddress(ls *lua.LState) int {
val := &mail.Address{
Name: ls.CheckString(1),
Address: ls.CheckString(2),
}
ud := wrapMailAddress(ls, val)
ls.Push(ud)
return 1
}
func wrapMailAddress(ls *lua.LState, val *mail.Address) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(mailAddressName))
return ud
}
func unwrapMailAddress(ud *lua.LUserData) (*mail.Address, bool) {
val, ok := ud.Value.(*mail.Address)
return val, ok
}
func checkMailAddress(ls *lua.LState, pos int) *mail.Address {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*mail.Address); ok {
return val
}
ls.ArgError(1, mailAddressName+" expected")
return nil
}
// Gets a field value from MailAddress user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func mailAddressIndex(ls *lua.LState) int {
a := checkMailAddress(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "name":
ls.Push(lua.LString(a.Name))
case "address":
ls.Push(lua.LString(a.Address))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// Sets a field value on MailAddress user object. This emulates a Lua table,
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
func mailAddressNewIndex(ls *lua.LState) int {
a := checkMailAddress(ls, 1)
index := ls.CheckString(2)
switch index {
case "name":
a.Name = ls.CheckString(3)
case "address":
a.Address = ls.CheckString(3)
default:
ls.RaiseError("invalid index %q", index)
}
return 0
}

View File

@@ -0,0 +1,56 @@
package luahost
import (
"net/mail"
"testing"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMailAddressGetters(t *testing.T) {
want := &mail.Address{
Name: "Roberto I",
Address: "ri@example.com",
}
script := `
assert(addr, "addr should not be nil")
want = "Roberto I"
got = addr.name
assert(got == want, string.format("got name %q, want %q", got, want))
want = "ri@example.com"
got = addr.address
assert(got == want, string.format("got address %q, want %q", got, want))
`
ls, _ := test.NewLuaState()
registerMailAddressType(ls)
ls.SetGlobal("addr", wrapMailAddress(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestMailAddressSetters(t *testing.T) {
want := &mail.Address{
Name: "Roberto I",
Address: "ri@example.com",
}
script := `
assert(addr, "addr should not be nil")
addr.name = "Roberto I"
addr.address = "ri@example.com"
`
ls, _ := test.NewLuaState()
registerMailAddressType(ls)
got := &mail.Address{}
ls.SetGlobal("addr", wrapMailAddress(ls, got))
require.NoError(t, ls.DoString(script))
assert.Equal(t, want, got)
}

View File

@@ -0,0 +1,135 @@
package luahost
import (
"fmt"
"net/mail"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
lua "github.com/yuin/gopher-lua"
)
const inboundMessageName = "inbound_message"
func registerInboundMessageType(ls *lua.LState) {
mt := ls.NewTypeMetatable(inboundMessageName)
ls.SetGlobal(inboundMessageName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newInboundMessage))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(inboundMessageIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(inboundMessageNewIndex))
}
func newInboundMessage(ls *lua.LState) int {
val := &event.InboundMessage{}
ud := wrapInboundMessage(ls, val)
ls.Push(ud)
return 1
}
func wrapInboundMessage(ls *lua.LState, val *event.InboundMessage) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inboundMessageName))
return ud
}
// Checks there is an InboundMessage at stack position `pos`, else throws Lua error.
func checkInboundMessage(ls *lua.LState, pos int) *event.InboundMessage {
ud := ls.CheckUserData(pos)
if v, ok := ud.Value.(*event.InboundMessage); ok {
return v
}
ls.ArgError(pos, inboundMessageName+" expected")
return nil
}
func unwrapInboundMessage(lv lua.LValue) (*event.InboundMessage, error) {
if ud, ok := lv.(*lua.LUserData); ok {
if v, ok := ud.Value.(*event.InboundMessage); ok {
return v, nil
}
}
return nil, fmt.Errorf("expected InboundMessage, got %q", lv.Type().String())
}
// Gets a field value from InboundMessage user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func inboundMessageIndex(ls *lua.LState) int {
m := checkInboundMessage(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "mailboxes":
lt := &lua.LTable{}
for _, v := range m.Mailboxes {
lt.Append(lua.LString(v))
}
ls.Push(lt)
case "from":
ls.Push(wrapMailAddress(ls, m.From))
case "to":
lt := &lua.LTable{}
for _, v := range m.To {
addr := v
lt.Append(wrapMailAddress(ls, addr))
}
ls.Push(lt)
case "subject":
ls.Push(lua.LString(m.Subject))
case "size":
ls.Push(lua.LNumber(m.Size))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// Sets a field value on InboundMessage user object. This emulates a Lua table,
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
func inboundMessageNewIndex(ls *lua.LState) int {
m := checkInboundMessage(ls, 1)
index := ls.CheckString(2)
switch index {
case "mailboxes":
lt := ls.CheckTable(3)
mailboxes := make([]string, 0, 16)
lt.ForEach(func(k, lv lua.LValue) {
if mb, ok := lv.(lua.LString); ok {
mailboxes = append(mailboxes, string(mb))
}
})
m.Mailboxes = mailboxes
case "from":
m.From = checkMailAddress(ls, 3)
case "to":
lt := ls.CheckTable(3)
to := make([]*mail.Address, 0, 16)
lt.ForEach(func(k, lv lua.LValue) {
if ud, ok := lv.(*lua.LUserData); ok {
// TODO should fail if wrong type + test.
if entry, ok := unwrapMailAddress(ud); ok {
to = append(to, entry)
}
}
})
m.To = to
case "subject":
m.Subject = ls.CheckString(3)
case "size":
ls.RaiseError("size is read-only")
default:
ls.RaiseError("invalid index %q", index)
}
return 0
}

View File

@@ -0,0 +1,75 @@
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/assert"
"github.com/stretchr/testify/require"
)
func TestInboundMessageGetters(t *testing.T) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{
{Name: "name2", Address: "addr2"},
{Name: "name3", Address: "addr3"},
},
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
assert_eq(msg.mailboxes, {"mb1", "mb2"})
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42, "msg.size")
assert_eq(msg.from.name, "name1", "from.name")
assert_eq(msg.from.address, "addr1", "from.address")
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, _ := test.NewLuaState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestInboundMessageSetters(t *testing.T) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{
{Name: "name2", Address: "addr2"},
{Name: "name3", Address: "addr3"},
},
Subject: "subj1",
}
script := `
assert(msg, "msg should not be nil")
msg.mailboxes = {"mb1", "mb2"}
msg.subject = "subj1"
msg.from = address.new("name1", "addr1")
msg.to = { address.new("name2", "addr2"), address.new("name3", "addr3") }
`
got := &event.InboundMessage{}
ls, _ := test.NewLuaState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, got))
require.NoError(t, ls.DoString(script))
assert.Equal(t, want, got)
}

View File

@@ -0,0 +1,228 @@
package luahost
import (
"errors"
"fmt"
lua "github.com/yuin/gopher-lua"
)
const (
inbucketName = "inbucket"
inbucketBeforeName = "inbucket_before"
inbucketAfterName = "inbucket_after"
)
// Inbucket is the primary Lua interface data structure.
type Inbucket struct {
After InbucketAfterFuncs
Before InbucketBeforeFuncs
}
// InbucketAfterFuncs holds references to Lua extension functions to be called async
// after Inbucket handles an event.
type InbucketAfterFuncs struct {
MessageDeleted *lua.LFunction
MessageStored *lua.LFunction
}
// InbucketBeforeFuncs holds references to Lua extension functions to be called
// before Inbucket handles an event.
type InbucketBeforeFuncs struct {
MailFromAccepted *lua.LFunction
MessageStored *lua.LFunction
RcptToAccepted *lua.LFunction
}
func registerInbucketTypes(ls *lua.LState) {
// inbucket type.
mt := ls.NewTypeMetatable(inbucketName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketIndex))
// inbucket global var.
ud := wrapInbucket(ls, &Inbucket{})
ls.SetGlobal(inbucketName, ud)
// inbucket.after type.
mt = ls.NewTypeMetatable(inbucketAfterName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketAfterNewIndex))
// inbucket.before type.
mt = ls.NewTypeMetatable(inbucketBeforeName)
ls.SetField(mt, "__index", ls.NewFunction(inbucketBeforeIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketBeforeNewIndex))
}
func wrapInbucket(ls *lua.LState, val *Inbucket) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketName))
return ud
}
func wrapInbucketAfter(ls *lua.LState, val *InbucketAfterFuncs) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketAfterName))
return ud
}
func wrapInbucketBefore(ls *lua.LState, val *InbucketBeforeFuncs) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketBeforeName))
return ud
}
func getInbucket(ls *lua.LState) (*Inbucket, error) {
lv := ls.GetGlobal(inbucketName)
if lv == nil {
return nil, errors.New("inbucket object was nil")
}
ud, ok := lv.(*lua.LUserData)
if !ok {
return nil, fmt.Errorf("inbucket object was type %s instead of UserData", lv.Type())
}
val, ok := ud.Value.(*Inbucket)
if !ok {
return nil, fmt.Errorf("inbucket object (%v) could not be cast", ud.Value)
}
return val, nil
}
func checkInbucket(ls *lua.LState, pos int) *Inbucket {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*Inbucket); ok {
return val
}
ls.ArgError(1, inbucketName+" expected")
return nil
}
func checkInbucketAfter(ls *lua.LState, pos int) *InbucketAfterFuncs {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*InbucketAfterFuncs); ok {
return val
}
ls.ArgError(1, inbucketAfterName+" expected")
return nil
}
func checkInbucketBefore(ls *lua.LState, pos int) *InbucketBeforeFuncs {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*InbucketBeforeFuncs); ok {
return val
}
ls.ArgError(1, inbucketBeforeName+" expected")
return nil
}
// inbucket getter.
func inbucketIndex(ls *lua.LState) int {
ib := checkInbucket(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "after":
ls.Push(wrapInbucketAfter(ls, &ib.After))
case "before":
ls.Push(wrapInbucketBefore(ls, &ib.Before))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// inbucket.after getter.
func inbucketAfterIndex(ls *lua.LState) int {
after := checkInbucketAfter(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "message_deleted":
ls.Push(funcOrNil(after.MessageDeleted))
case "message_stored":
ls.Push(funcOrNil(after.MessageStored))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// inbucket.after setter.
func inbucketAfterNewIndex(ls *lua.LState) int {
m := checkInbucketAfter(ls, 1)
index := ls.CheckString(2)
switch index {
case "message_deleted":
m.MessageDeleted = ls.CheckFunction(3)
case "message_stored":
m.MessageStored = ls.CheckFunction(3)
default:
ls.RaiseError("invalid inbucket.after index %q", index)
}
return 0
}
// inbucket.before getter.
func inbucketBeforeIndex(ls *lua.LState) int {
before := checkInbucketBefore(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "mail_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)
}
return 1
}
// inbucket.before setter.
func inbucketBeforeNewIndex(ls *lua.LState) int {
m := checkInbucketBefore(ls, 1)
index := ls.CheckString(2)
switch index {
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)
}
return 0
}
func funcOrNil(f *lua.LFunction) lua.LValue {
if f == nil {
return lua.LNil
}
return f
}

View File

@@ -0,0 +1,102 @@
package luahost
import (
"testing"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/require"
)
func TestInbucketAfterFuncs(t *testing.T) {
// This Script registers each function and calls it. No effort is made to use the arguments
// that Inbucket expects, this is only to validate the inbucket.after data structure getters
// and setters.
script := `
assert(inbucket, "inbucket should not be nil")
assert(inbucket.after, "inbucket.after should not be nil")
local fns = { "message_deleted", "message_stored" }
-- Verify functions start off nil.
for i, name in ipairs(fns) do
assert(inbucket.after[name] == nil, "after." .. name .. " should be nil")
end
-- Test function to track func calls made, ensures no crossed wires.
local calls = {}
function makeTestFunc(create_name)
return function(call_name)
calls[create_name] = call_name
end
end
-- Set after functions, verify not nil, and call them.
for i, name in ipairs(fns) do
inbucket.after[name] = makeTestFunc(name)
assert(inbucket.after[name], "after." .. name .. " should not be nil")
end
-- Call each function. Separate loop to verify final state in 'calls'.
for i, name in ipairs(fns) do
inbucket.after[name](name)
end
-- Verify functions were called.
for i, name in ipairs(fns) do
assert(calls[name], "after." .. name .. " should have been called")
assert(calls[name] == name,
string.format("after.%s was called with incorrect argument %s", name, calls[name]))
end
`
ls, _ := test.NewLuaState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}
func TestInbucketBeforeFuncs(t *testing.T) {
// This Script registers each function and calls it. No effort is made to use the arguments
// that Inbucket expects, this is only to validate the inbucket.before data structure getters
// and setters.
script := `
assert(inbucket, "inbucket should not be nil")
assert(inbucket.before, "inbucket.before should not be nil")
local fns = { "mail_from_accepted", "message_stored", "rcpt_to_accepted" }
-- Verify functions start off nil.
for i, name in ipairs(fns) do
assert(inbucket.before[name] == nil, "before." .. name .. " should be nil")
end
-- Test function to track func calls made, ensures no crossed wires.
local calls = {}
function makeTestFunc(create_name)
return function(call_name)
calls[create_name] = call_name
end
end
-- Set before functions, verify not nil, and call them.
for i, name in ipairs(fns) do
inbucket.before[name] = makeTestFunc(name)
assert(inbucket.before[name], "before." .. name .. " should not be nil")
end
-- Call each function. Separate loop to verify final state in 'calls'.
for i, name in ipairs(fns) do
inbucket.before[name](name)
end
-- Verify functions were called.
for i, name in ipairs(fns) do
assert(calls[name], "before." .. name .. " should have been called")
assert(calls[name] == name,
string.format("before.%s was called with incorrect argument %s", name, calls[name]))
end
`
ls, _ := test.NewLuaState()
registerInbucketTypes(ls)
require.NoError(t, ls.DoString(script))
}

View File

@@ -0,0 +1,120 @@
package luahost
import (
"net/mail"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
lua "github.com/yuin/gopher-lua"
)
const messageMetadataName = "message_metadata"
func registerMessageMetadataType(ls *lua.LState) {
mt := ls.NewTypeMetatable(messageMetadataName)
ls.SetGlobal(messageMetadataName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newMessageMetadata))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(messageMetadataIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(messageMetadataNewIndex))
}
func newMessageMetadata(ls *lua.LState) int {
val := &event.MessageMetadata{}
ud := wrapMessageMetadata(ls, val)
ls.Push(ud)
return 1
}
func wrapMessageMetadata(ls *lua.LState, val *event.MessageMetadata) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(messageMetadataName))
return ud
}
func checkMessageMetadata(ls *lua.LState, pos int) *event.MessageMetadata {
ud := ls.CheckUserData(pos)
if v, ok := ud.Value.(*event.MessageMetadata); ok {
return v
}
ls.ArgError(1, messageMetadataName+" expected")
return nil
}
// Gets a field value from MessageMetadata user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func messageMetadataIndex(ls *lua.LState) int {
m := checkMessageMetadata(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "mailbox":
ls.Push(lua.LString(m.Mailbox))
case "id":
ls.Push(lua.LString(m.ID))
case "from":
ls.Push(wrapMailAddress(ls, m.From))
case "to":
lt := &lua.LTable{}
for _, v := range m.To {
lt.Append(wrapMailAddress(ls, v))
}
ls.Push(lt)
case "date":
ls.Push(lua.LNumber(m.Date.Unix()))
case "subject":
ls.Push(lua.LString(m.Subject))
case "size":
ls.Push(lua.LNumber(m.Size))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// Sets a field value on MessageMetadata user object. This emulates a Lua table,
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
func messageMetadataNewIndex(ls *lua.LState) int {
m := checkMessageMetadata(ls, 1)
index := ls.CheckString(2)
switch index {
case "mailbox":
m.Mailbox = ls.CheckString(3)
case "id":
m.ID = ls.CheckString(3)
case "from":
m.From = checkMailAddress(ls, 3)
case "to":
lt := ls.CheckTable(3)
to := make([]*mail.Address, 0, 16)
lt.ForEach(func(k, lv lua.LValue) {
if ud, ok := lv.(*lua.LUserData); ok {
// TODO should fail if wrong type + test.
if entry, ok := unwrapMailAddress(ud); ok {
to = append(to, entry)
}
}
})
m.To = to
case "date":
m.Date = time.Unix(ls.CheckInt64(3), 0)
case "subject":
m.Subject = ls.CheckString(3)
case "size":
m.Size = ls.CheckInt64(3)
default:
ls.RaiseError("invalid index %q", index)
}
return 0
}

View File

@@ -0,0 +1,87 @@
package luahost
import (
"net/mail"
"testing"
"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"
)
func TestMessageMetadataGetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42, "msg.size")
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", "to.name")
assert_eq(msg.to[1].address, "addr2", "to.address")
assert_eq(msg.date, 981173106, "msg.date")
`
ls, _ := test.NewLuaState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestMessageMetadataSetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
msg.mailbox = "mb1"
msg.id = "id1"
msg.subject = "subj1"
msg.size = 42
msg.from = address.new("name1", "addr1")
msg.to = { address.new("name2", "addr2") }
msg.date = 981173106
`
got := &event.MessageMetadata{}
ls, _ := test.NewLuaState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))
require.NoError(t, ls.DoString(script))
// Timezones will cause a naive comparison to fail.
assert.Equal(t, want.Date.Unix(), got.Date.Unix())
now := time.Now()
want.Date = now
got.Date = now
assert.Equal(t, want, got)
}

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

@@ -0,0 +1,259 @@
package luahost
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
// ErrNoScript signals that the Lua script file was not present.
var ErrNoScript error = errors.New("no script file present")
// Host of Lua extensions.
type Host struct {
extHost *extension.Host
pool *statePool
logContext zerolog.Context
}
// New constructs a new Lua Host, pre-compiling the source.
func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
scriptPath := conf.Path
if scriptPath == "" {
return nil, nil
}
logContext := log.With().Str("module", "lua")
logger := logContext.Str("phase", "startup").Str("path", scriptPath).Logger()
// Pre-load, parse, and compile script.
if fi, err := os.Stat(scriptPath); err != nil {
logger.Info().Msg("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)
}
logger.Info().Msg("Loading script")
file, err := os.Open(scriptPath)
if err != nil {
return nil, err
}
defer file.Close()
return NewFromReader(logContext.Logger(), extHost, bufio.NewReader(file), scriptPath)
}
// NewFromReader constructs a new Lua Host, loading Lua source from the provided reader.
// The provided path is used in logging and error messages.
func NewFromReader(logger zerolog.Logger, extHost *extension.Host, r io.Reader, path string) (*Host, error) {
startLogger := logger.With().Str("phase", "startup").Str("path", path).Logger()
// Pre-parse, and compile script.
chunk, err := parse.Parse(r, path)
if err != nil {
return nil, err
}
proto, err := lua.Compile(chunk, path)
if err != nil {
return nil, err
}
// Build the pool and confirm LState is retrievable.
pool := newStatePool(logger, proto)
h := &Host{extHost: extHost, pool: pool, logContext: logger.With()}
if ls, err := pool.getState(); err == nil {
h.wireFunctions(startLogger, ls)
// State creation works, put it back.
pool.putState(ls)
} else {
return nil, err
}
return h, nil
}
// CreateChannel creates a channel and places it into the named global variable
// in newly created LStates.
func (h *Host) CreateChannel(name string) chan lua.LValue {
return h.pool.createChannel(name)
}
// Detects global lua event listener functions and wires them up.
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
ib, err := getInbucket(ls)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to get inbucket global")
}
events := h.extHost.Events
const listenerName string = "lua"
if ib.After.MessageDeleted != nil {
events.AfterMessageDeleted.AddListener(listenerName, h.handleAfterMessageDeleted)
}
if ib.After.MessageStored != nil {
events.AfterMessageStored.AddListener(listenerName, h.handleAfterMessageStored)
}
if ib.Before.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) {
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_deleted")
if !ok {
return
}
defer h.pool.putState(ls)
// Call lua function.
logger.Debug().Msgf("Calling Lua function with %+v", msg)
if err := ls.CallByParam(
lua.P{Fn: ib.After.MessageDeleted, NRet: 0, Protect: true},
wrapMessageMetadata(ls, &msg),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
}
}
func (h *Host) handleAfterMessageStored(msg event.MessageMetadata) {
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
if !ok {
return
}
defer h.pool.putState(ls)
// Call lua function.
logger.Debug().Msgf("Calling Lua function with %+v", msg)
if err := ls.CallByParam(
lua.P{Fn: ib.After.MessageStored, NRet: 0, Protect: true},
wrapMessageMetadata(ls, &msg),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
}
}
func (h *Host) 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", session)
if err := ls.CallByParam(
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)
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
}
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
}
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
}
func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.InboundMessage {
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.message_stored")
if !ok {
return nil
}
defer h.pool.putState(ls)
logger.Debug().Msgf("Calling Lua function with %+v", msg)
if err := ls.CallByParam(
lua.P{Fn: ib.Before.MessageStored, NRet: 1, Protect: true},
wrapInboundMessage(ls, &msg),
); err != nil {
logger.Error().Err(err).Msg("Failed to call Lua function")
return nil
}
lval := ls.Get(-1)
ls.Pop(1)
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
if lua.LVIsFalse(lval) {
return nil
}
result, err := unwrapInboundMessage(lval)
if err != nil {
logger.Error().Err(err).Msg("Bad response from Lua Function")
}
return result
}
// Common preparation for calling Lua functions.
func (h *Host) prepareInbucketFuncCall(funcName string) (logger zerolog.Logger, ls *lua.LState, ib *Inbucket, ok bool) {
logger = h.logContext.Str("event", funcName).Logger()
ls, err := h.pool.getState()
if err != nil {
logger.Error().Err(err).Msg("Failed to get Lua state instance from pool")
return logger, nil, nil, false
}
ib, err = getInbucket(ls)
if err != nil {
logger.Error().Err(err).Msg("Failed to obtain Lua inbucket object")
return logger, nil, nil, false
}
return logger, ls, ib, true
}

View File

@@ -0,0 +1,303 @@
package luahost_test
import (
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var consoleLogger = zerolog.New(zerolog.NewConsoleWriter())
func TestEmptyScript(t *testing.T) {
script := ""
extHost := extension.NewHost()
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
}
func TestLogger(t *testing.T) {
script := `
local logger = require("logger")
logger.info("_test log entry_", {})
`
extHost := extension.NewHost()
output := &strings.Builder{}
logger := zerolog.New(output)
_, err := luahost.NewFromReader(logger, extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
assert.Contains(t, output.String(), "_test log entry_")
}
func TestAfterMessageDeleted(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_deleted(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(asserts_ok)
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, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageDeleted.Emit(msg)
test.AssertNotified(t, notify)
}
func TestAfterMessageStored(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_stored(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(asserts_ok)
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, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageStored.Emit(msg)
test.AssertNotified(t, notify)
}
func TestBeforeMailFromAccepted(t *testing.T) {
// Register lua event listener.
script := `
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(test.LuaInit+script), "test.lua")
require.NoError(t, err)
{
// 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.
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)
}
}
}
func TestBeforeMessageStored(t *testing.T) {
// Event to send.
msg := event.InboundMessage{
Mailboxes: []string{"one", "two"},
From: &mail.Address{Name: "From Name", Address: "from@example.com"},
To: []*mail.Address{
{Name: "To1 Name", Address: "to1@example.com"},
{Name: "To2 Name", Address: "to2@example.com"},
},
Subject: "inbound subj",
Size: 42,
}
// Register lua event listener.
script := `
async = true
function inbucket.before.message_stored(msg)
-- Verify incoming values.
assert_eq(msg.mailboxes, {"one", "two"})
assert_eq(msg.from.name, "From Name")
assert_eq(msg.from.address, "from@example.com")
assert_eq(2, #msg.to, "#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, "msg.size")
notify:send(asserts_ok)
-- Generate response.
res = inbound_message.new()
res.mailboxes = {"resone", "restwo"}
res.from = address.new("Res From", "res@example.com")
res.to = {
address.new("To1 Res", "res1@example.com"),
address.new("To2 Res", "res2@example.com"),
}
res.subject = "res subj"
return res
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost,
strings.NewReader(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.NotNil(t, got, "Expected result from Emit()")
// Verify Lua assertions passed.
test.AssertNotified(t, notify)
// Verify response values.
want := &event.InboundMessage{
Mailboxes: []string{"resone", "restwo"},
From: &mail.Address{Name: "Res From", Address: "res@example.com"},
To: []*mail.Address{
{Name: "To1 Res", Address: "res1@example.com"},
{Name: "To2 Res", Address: "res2@example.com"},
},
Subject: "res subj",
Size: 0,
}
assert.Equal(t, want, got, "Response InboundMessage did not match")
}
func 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

@@ -0,0 +1,113 @@
package luahost
import (
"net/http"
"sync"
"github.com/cjoudrey/gluahttp"
"github.com/cosmotek/loguago"
json "github.com/inbucket/gopher-json"
"github.com/rs/zerolog"
lua "github.com/yuin/gopher-lua"
)
type statePool struct {
sync.Mutex
funcProto *lua.FunctionProto // Compiled lua.
states []*lua.LState // Pool of available LStates.
channels map[string]chan lua.LValue // Global interop channels.
logger zerolog.Logger // Logger exported to Lua scripts.
}
func newStatePool(logger zerolog.Logger, funcProto *lua.FunctionProto) *statePool {
return &statePool{
funcProto: funcProto,
channels: make(map[string]chan lua.LValue),
logger: logger,
}
}
// newState creates a new LState and configures it. Lock must be held.
func (lp *statePool) newState() (*lua.LState, error) {
ls := lua.NewState()
logger := loguago.NewLogger(lp.logger)
// Load supplemental native modules.
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
ls.PreloadModule("json", json.Loader)
ls.PreloadModule("logger", logger.Loader)
// Setup channels.
for name, ch := range lp.channels {
ls.SetGlobal(name, lua.LChannel(ch))
}
// Register custom types.
registerInboundMessageType(ls)
registerInbucketTypes(ls)
registerMailAddressType(ls)
registerMessageMetadataType(ls)
registerSMTPResponseType(ls)
registerSMTPSessionType(ls)
// Run compiled script.
ls.Push(ls.NewFunctionFromProto(lp.funcProto))
if err := ls.PCall(0, lua.MultRet, nil); err != nil {
return nil, err
}
return ls, nil
}
// getState returns a free LState, or creates a new one.
func (lp *statePool) getState() (*lua.LState, error) {
lp.Lock()
defer lp.Unlock()
ln := len(lp.states)
if ln == 0 {
return lp.newState()
}
state := lp.states[ln-1]
lp.states = lp.states[0 : ln-1]
return state, nil
}
// putState returns the LState to the pool.
func (lp *statePool) putState(state *lua.LState) {
if state.IsClosed() {
return
}
// Clear stack.
state.Pop(state.GetTop())
lp.Lock()
defer lp.Unlock()
lp.states = append(lp.states, state)
}
// createChannel creates a new channel, which will become a global variable in
// newly created LStates. We also destroy any pooled states.
//
// Warning: There may still be checked out LStates that will not have the value
// set, which could be put back into the pool.
func (lp *statePool) createChannel(name string) chan lua.LValue {
lp.Lock()
defer lp.Unlock()
ch := make(chan lua.LValue, 10)
lp.channels[name] = ch
// Flush state pool.
for _, s := range lp.states {
s.Close()
}
lp.states = lp.states[:0]
return ch
}

View File

@@ -0,0 +1,102 @@
package luahost
import (
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
func makeEmptyPool() *statePool {
source := strings.NewReader("-- Empty source")
chunk, err := parse.Parse(source, "from string")
if err != nil {
panic(err)
}
proto, err := lua.Compile(chunk, "from string")
if err != nil {
panic(err)
}
return newStatePool(zerolog.Nop(), proto)
}
func TestPoolGetsDistinct(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
b, err := pool.getState()
require.NoError(t, err)
if a == b {
t.Error("Got pool a == b, expected distinct pools")
}
}
func TestPoolGrowsWithPuts(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
b, err := pool.getState()
require.NoError(t, err)
assert.Empty(t, pool.states, "Wanted pool to be empty")
pool.putState(a)
pool.putState(b)
want := 2
if got := len(pool.states); got != want {
t.Errorf("len pool.states got %v, want %v", got, want)
}
}
// Closed LStates should not be added to the pool.
func TestPoolPutDiscardsClosed(t *testing.T) {
pool := makeEmptyPool()
a, err := pool.getState()
require.NoError(t, err)
assert.Empty(t, pool.states, "Wanted pool to be empty")
a.Close()
pool.putState(a)
assert.Empty(t, pool.states, "Wanted pool to remain empty")
}
func TestPoolPutClearsStack(t *testing.T) {
pool := makeEmptyPool()
ls, err := pool.getState()
require.NoError(t, err)
assert.Empty(t, pool.states, "Wanted pool to be empty")
// Setup stack.
ls.Push(lua.LNumber(4))
ls.Push(lua.LString("bacon"))
require.Equal(t, 2, ls.GetTop(), "Want stack to have two items")
// Return and verify stack cleared.
pool.putState(ls)
assert.Len(t, pool.states, 1, "Wanted pool to have one item")
require.Equal(t, 0, ls.GetTop(), "Want stack to be empty")
}
func TestPoolSetsChannels(t *testing.T) {
pool := makeEmptyPool()
pool.createChannel("test_chan")
s, err := pool.getState()
require.NoError(t, err)
got := s.GetGlobal("test_chan")
assert.Equal(t, lua.LTChannel, got.Type(),
"Got global type %v, wanted LTChannel", got.Type().String())
}

View File

@@ -2,29 +2,32 @@ package message
import (
"bytes"
"fmt"
"io"
"net/mail"
"strings"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/jhillyerd/enmime/v2"
"github.com/rs/zerolog/log"
)
// recvdTimeFmt to use in generated Received header.
const recvdTimeFmt = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Manager is the interface controllers use to interact with messages.
type Manager interface {
Deliver(
to *policy.Recipient,
from string,
from *policy.Origin,
recipients []*policy.Recipient,
prefix string,
recvdHeader string,
content []byte,
) (id string, err error)
GetMetadata(mailbox string) ([]*Metadata, error)
) error
GetMetadata(mailbox string) ([]*event.MessageMetadata, error)
GetMessage(mailbox, id string) (*Message, error)
MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error
@@ -37,74 +40,116 @@ type Manager interface {
type StoreManager struct {
AddrPolicy *policy.Addressing
Store storage.Store
Hub *msghub.Hub
ExtHost *extension.Host
}
// Deliver submits a new message to the store.
func (s *StoreManager) Deliver(
to *policy.Recipient,
from string,
from *policy.Origin,
recipients []*policy.Recipient,
prefix string,
recvdHeader string,
source []byte,
) (string, error) {
// TODO enmime is too heavy for this step, only need header.
// Go's header parsing isn't good enough, so this is blocked on enmime issue #64.
env, err := enmime.ReadEnvelope(bytes.NewReader(source))
) error {
logger := log.With().Str("module", "message").Logger()
// Parse envelope headers.
header, err := enmime.DecodeHeaders(source)
if err != nil {
return "", err
return err
}
fromaddr, err := env.AddressList("From")
if err != nil || len(fromaddr) == 0 {
fromaddr = []*mail.Address{{Address: from}}
fromAddrs, err := enmime.ParseAddressList(header.Get("From"))
if err != nil || len(fromAddrs) == 0 {
// Failed to parse From header, use SMTP MAIL FROM instead.
fromAddrs = make([]*mail.Address, 1)
fromAddrs[0] = &from.Address
}
toaddr, err := env.AddressList("To")
toAddrs, err := enmime.ParseAddressList(header.Get("To"))
if err != nil {
toaddr = make([]*mail.Address, len(recipients))
// Failed to parse To header, use SMTP RCPT TO instead.
toAddrs = make([]*mail.Address, len(recipients))
for i, torecip := range recipients {
toaddr[i] = &torecip.Address
toAddrs[i] = &torecip.Address
}
}
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
delivery := &Delivery{
Meta: Metadata{
Mailbox: to.Mailbox,
From: fromaddr[0],
To: toaddr,
Date: time.Now(),
Subject: env.GetHeader("Subject"),
},
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
subject := header.Get("Subject")
now := time.Now()
tstamp := now.UTC().Format(recvdTimeFmt)
// Process inbound message through extensions.
mailboxes := make([]string, 0, len(recipients))
for _, recip := range recipients {
mailboxes = append(mailboxes, recip.Mailbox)
}
id, err := s.Store.AddMessage(delivery)
if err != nil {
return "", err
// Construct InboundMessage event and process through extensions.
inbound := &event.InboundMessage{
Mailboxes: mailboxes,
From: fromAddrs[0],
To: toAddrs,
Subject: subject,
Size: int64(len(source)),
}
if s.Hub != nil {
// Broadcast message information.
broadcast := msghub.Message{
Mailbox: to.Mailbox,
ID: id,
From: delivery.From().String(),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),
Size: delivery.Size(),
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
if extResult == nil {
// Use address policy to determine deliverable mailboxes.
mailboxes = mailboxes[:0]
for _, recip := range recipients {
if recip.ShouldStore() {
mailboxes = append(mailboxes, recip.Mailbox)
}
}
s.Hub.Dispatch(broadcast)
inbound.Mailboxes = mailboxes
} else {
// Event response overrides destination mailboxes and address policy.
inbound = extResult
}
return id, nil
// Deliver to each mailbox.
for _, mb := range inbound.Mailboxes {
// Append recipient and timestamp to generated Received header.
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", from.Address.Address)
// Deliver message.
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
delivery := &Delivery{
Meta: event.MessageMetadata{
Mailbox: mb,
From: inbound.From,
To: inbound.To,
Date: now,
Subject: inbound.Subject,
Size: inbound.Size,
},
Reader: io.MultiReader(strings.NewReader(returnPath), strings.NewReader(recvd), bytes.NewReader(source)),
}
id, err := s.Store.AddMessage(delivery)
if err != nil {
logger.Error().Str("mailbox", mb).Err(err).Msg("Delivery failed")
return err
}
// Emit message stored event.
event := delivery.Meta
event.ID = id
s.ExtHost.Events.AfterMessageStored.Emit(&event)
}
return nil
}
// GetMetadata returns a slice of metadata for the specified mailbox.
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) {
func (s *StoreManager) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
messages, err := s.Store.GetMessages(mailbox)
if err != nil {
return nil, err
}
metas := make([]*Metadata, len(messages))
metas := make([]*event.MessageMetadata, len(messages))
for i, sm := range messages {
metas[i] = makeMetadata(sm)
metas[i] = MakeMetadata(sm)
}
return metas, nil
}
@@ -124,8 +169,8 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
return nil, err
}
_ = r.Close()
header := makeMetadata(sm)
return &Message{Metadata: *header, env: env}, nil
header := MakeMetadata(sm)
return &Message{MessageMetadata: *header, env: env}, nil
}
// MarkSeen marks the message as having been read.
@@ -159,9 +204,9 @@ func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
return s.AddrPolicy.ExtractMailbox(mailbox)
}
// makeMetadata populates Metadata from a storage.Message.
func makeMetadata(m storage.Message) *Metadata {
return &Metadata{
// MakeMetadata populates Metadata from a storage.Message.
func MakeMetadata(m storage.Message) *event.MessageMetadata {
return &event.MessageMetadata{
Mailbox: m.Mailbox(),
ID: m.ID(),
From: m.From(),

595
pkg/message/manager_test.go Normal file
View File

@@ -0,0 +1,595 @@
package message_test
import (
"fmt"
"io"
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeliverStoresMessages(t *testing.T) {
sm, _ := testStoreManager()
// Attempt to deliver a message to two mailboxes.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte(`From: from@example.com
To: u1@example.com, u2@example.com
Subject: tsub
test email`),
)
require.NoError(t, err)
assertMessageCount(t, sm, "u1@example.com", 1)
assertMessageCount(t, sm, "u2@example.com", 1)
}
func TestDeliverStoresMessageNoFromHeader(t *testing.T) {
sm, _ := testStoreManager()
// Attempt to deliver a message to two mailboxes.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte(`To: u1@example.com, u2@example.com
Subject: tsub
test email`),
)
require.NoError(t, err)
assertMessageCount(t, sm, "u1@example.com", 1)
assertMessageCount(t, sm, "u2@example.com", 1)
}
func TestDeliverStoresMessageNoToHeader(t *testing.T) {
sm, _ := testStoreManager()
// Attempt to deliver a message to two mailboxes.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte(`From: from@example.com
Subject: tsub
test email`),
)
require.NoError(t, err)
assertMessageCount(t, sm, "u1@example.com", 1)
assertMessageCount(t, sm, "u2@example.com", 1)
}
func TestDeliverRespectsRecipientPolicy(t *testing.T) {
sm, _ := testStoreManager()
// Attempt to deliver a message to two mailboxes.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@nostore.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
// Expect empty mailbox for nostore domain.
assertMessageCount(t, sm, "u1@nostore.com", 0)
assertMessageCount(t, sm, "u2@example.com", 1)
}
func TestDeliverEmitsBeforeMessageStoredEventToHeader(t *testing.T) {
sm, extHost := testStoreManager()
// Register function to receive event.
var got *event.InboundMessage
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
got = &msg
return nil
})
// Deliver a message to trigger event, To header differs from RCPT TO.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte(`From: from@example.com
To: u1@example.com, u3@external.com
Subject: tsub
test email`),
); err != nil {
t.Fatal(err)
}
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
assert.Equal(t, []*mail.Address{
{Name: "", Address: "u1@example.com"},
{Name: "", Address: "u3@external.com"},
}, got.To, "To not equal")
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
assert.Equal(t, int64(84), got.Size, "Size not equal")
}
func TestDeliverEmitsBeforeMessageStoredEventRcptTo(t *testing.T) {
sm, extHost := testStoreManager()
// Register function to receive event.
var got *event.InboundMessage
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
got = &msg
return nil
})
// Deliver a message to trigger event, lacks To header.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
assert.Equal(t, []*mail.Address{
{Name: "", Address: "u1@example.com"},
{Name: "", Address: "u2@example.com"},
}, got.To, "To not equal")
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
assert.Equal(t, int64(48), got.Size, "Size not equal")
}
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxes(t *testing.T) {
sm, extHost := testStoreManager()
// Register function to receive event.
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
// Listener rewrites destination mailboxes.
resp := msg
resp.Mailboxes = []string{"new1@example.com", "new2@nostore.com"}
return &resp
})
// Deliver a message to trigger event.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\r\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
// Expect messages in only the mailboxes in the event response, and for the DiscardDomains
// policy to be ignored for nostore.com.
assertMessageCount(t, sm, "u1@example.com", 0)
assertMessageCount(t, sm, "u2@example.com", 0)
assertMessageCount(t, sm, "new1@example.com", 1)
assertMessageCount(t, sm, "new2@nostore.com", 1)
}
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxesEmpty(t *testing.T) {
sm, extHost := testStoreManager()
// Register function to receive event.
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
// Listener clears destination mailboxes.
resp := msg
resp.Mailboxes = []string{}
return &resp
})
// Deliver a message to trigger event.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\r\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
// Expect no messages the mailboxes.
assertMessageCount(t, sm, "u1@example.com", 0)
assertMessageCount(t, sm, "u2@example.com", 0)
}
func TestDeliverUsesBeforeMessageStoredEventResponseFields(t *testing.T) {
sm, extHost := testStoreManager()
// Register function to receive event.
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
// Listener rewrites destination mailboxes.
msg.Subject = "event subj"
msg.From = &mail.Address{Address: "from@event.com", Name: "From Event"}
// Changing To does not affect destination mailbox(es).
msg.To = []*mail.Address{
{Address: "to@event.com", Name: "To Event"},
{Address: "to2@event.com", Name: "To 2 Event"},
}
// Size is read only, should have no effect.
msg.Size = 12345
return &msg
})
// Deliver a message to trigger event.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1},
"Received: xyz\r\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
// Verify single message stored.
metadata, err := sm.GetMetadata("u1@example.com")
require.NoError(t, err)
require.Len(t, metadata, 1, "mailbox has incorrect # of messages")
got := metadata[0]
// Verify metadata fields were overridden by event response values.
assert.Equal(t, "event subj", got.Subject, "Subject didn't match")
assert.Equal(t, "from@event.com", got.From.Address, "From Address didn't match")
assert.Equal(t, "From Event", got.From.Name, "From Name didn't match")
require.Len(t, got.To, 2)
assert.Equal(t, "to@event.com", got.To[0].Address, "To Address didn't match")
assert.Equal(t, "To Event", got.To[0].Name, "To Name didn't match")
assert.Equal(t, "to2@event.com", got.To[1].Address, "To Address didn't match")
assert.Equal(t, "To 2 Event", got.To[1].Name, "To Name didn't match")
assert.NotEqual(t, 12345, got.Size, "Size is read only")
}
func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
sm, extHost := testStoreManager()
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
// Deliver a message to trigger event.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip, _ := sm.AddrPolicy.NewRecipient("to@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip},
"Received: xyz\n",
[]byte("From: from@example.com\nSubject: events\n\ntest email."),
); err != nil {
t.Fatal(err)
}
got, err := listener()
require.NoError(t, err)
assert.NotNil(t, got, "No event received, or it was nil")
assertMessageCount(t, sm, "to@example.com", 1)
// 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) {
sm, extHost := testStoreManager()
// Register function to receive Before event.
extHost.Events.BeforeMessageStored.AddListener(
"test",
func(msg event.InboundMessage) *event.InboundMessage {
// Listener rewrites destination mailboxes.
resp := msg
resp.Mailboxes = []string{"new1@example.com", "new2@example.com"}
return &resp
})
// After event listener.
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 2)
// Deliver a message to trigger events.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
if err := sm.Deliver(
origin,
[]*policy.Recipient{recip1, recip2},
"Received: xyz\r\n",
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
); err != nil {
t.Fatal(err)
}
// Confirm mailbox names overridden by `Before` were sent to `After` event. Order is
// not guaranteed.
got1, err := listener()
require.NoError(t, err)
got2, err := listener()
require.NoError(t, err)
got := []string{got1.Mailbox, got2.Mailbox}
assert.Contains(t, got, "new1@example.com")
assert.Contains(t, got, "new2@example.com")
}
func TestGetMessage(t *testing.T) {
sm, _ := testStoreManager()
// Add a test message.
subject := "getMessage1"
id := addTestMessage(sm, "get-box", subject)
// Verify retrieval of the test message.
msg, err := sm.GetMessage("get-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.Equal(t, subject, msg.Subject)
assert.Contains(t, msg.Text(), fmt.Sprintf("about %q", subject))
}
func TestMarkSeen(t *testing.T) {
sm, _ := testStoreManager()
// Add a test message.
subject := "getMessage1"
id := addTestMessage(sm, "seen-box", subject)
// Verify test message unseen.
msg, err := sm.GetMessage("seen-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.False(t, msg.Seen, "msg should be unseen")
err = sm.MarkSeen("seen-box", id)
require.NoError(t, err, "MarkSeen should succeed")
// Verify test message seen.
msg, err = sm.GetMessage("seen-box", id)
require.NoError(t, err, "GetMessage must succeed")
require.NotNil(t, msg, "GetMessage must return a result")
assert.True(t, msg.Seen, "msg should have been seen")
}
func TestRemoveMessage(t *testing.T) {
sm, _ := testStoreManager()
// Add test messages.
id1 := addTestMessage(sm, "rm-box", "subject 1")
id2 := addTestMessage(sm, "rm-box", "subject 2")
id3 := addTestMessage(sm, "rm-box", "subject 3")
got, err := sm.GetMetadata("rm-box")
require.NoError(t, err)
require.Len(t, got, 3)
// Delete message 2 and verify.
err = sm.RemoveMessage("rm-box", id2)
require.NoError(t, err)
got, err = sm.GetMetadata("rm-box")
require.NoError(t, err)
require.Len(t, got, 2, "Should be 2 messages remaining")
gotIDs := make([]string, 0, 3)
for _, msg := range got {
gotIDs = append(gotIDs, msg.ID)
}
assert.Contains(t, gotIDs, id1)
assert.Contains(t, gotIDs, id3)
}
func TestPurgeMessages(t *testing.T) {
sm, _ := testStoreManager()
// Add test messages.
_ = addTestMessage(sm, "purge-box", "subject 1")
_ = addTestMessage(sm, "purge-box", "subject 2")
_ = addTestMessage(sm, "purge-box", "subject 3")
got, err := sm.GetMetadata("purge-box")
require.NoError(t, err)
require.Len(t, got, 3)
// Purge and verify.
err = sm.PurgeMessages("purge-box")
require.NoError(t, err)
got, err = sm.GetMetadata("purge-box")
require.NoError(t, err)
assert.Empty(t, got, "Purge should remove all mailbox messages")
}
func TestSourceReader(t *testing.T) {
sm, _ := testStoreManager()
recvdHeader := "Received: xyz\n"
msgSource := `From: from@example.com
To: u1@example.com, u2@example.com
Subject: tsub
test email`
// Deliver mesage.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
err := sm.Deliver(origin, []*policy.Recipient{recip1}, recvdHeader, []byte(msgSource))
require.NoError(t, err)
// Find message ID.
msgs, err := sm.GetMetadata("u1@example.com")
require.NoError(t, err, "Failed to read mailbox")
require.Len(t, msgs, 1, "Unexpected mailbox len")
id := msgs[0].ID
// Read back and verify source.
r, err := sm.SourceReader("u1@example.com", id)
require.NoError(t, err, "SourceReader must succeed")
gotBytes, err := io.ReadAll(r)
require.NoError(t, err, "Failed to read source")
got := string(gotBytes)
assert.Contains(t, got, recvdHeader, "Source should contain received header")
assert.Contains(t, got, msgSource, "Source should contain original message source")
}
func TestMailboxForAddress(t *testing.T) {
// Configured for FullNaming.
sm, _ := testStoreManager()
addr := "u1@example.com"
got, err := sm.MailboxForAddress(addr)
require.NoError(t, err)
assert.Equal(t, addr, got, "FullNaming mode should return a full address for mailbox")
}
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()
sm := &message.StoreManager{
AddrPolicy: &policy.Addressing{
Config: &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
DefaultAccept: true,
DefaultStore: true,
RejectDomains: []string{"noaccept.com"},
DiscardDomains: []string{"nostore.com"},
},
},
},
Store: test.NewStore(),
ExtHost: extHost,
}
return sm, extHost
}
// Adds a test message to the provided store, returning the new message ID.
func addTestMessage(sm *message.StoreManager, mailbox string, subject string) string {
from := mail.Address{Name: "From Test", Address: "from@example.com"}
to := mail.Address{Name: "To Test", Address: "to@example.com"}
delivery := &message.Delivery{
Meta: event.MessageMetadata{
Mailbox: mailbox,
From: &from,
To: []*mail.Address{&to},
Date: time.Now(),
Subject: subject,
},
Reader: strings.NewReader(fmt.Sprintf(
"From: %s\nTo: %s\nSubject: %s\n\nTest message about %q\n",
from, to, subject, subject,
)),
}
id, err := sm.Store.AddMessage(delivery)
if err != nil {
panic(err)
}
return id
}
func assertMessageCount(t *testing.T, sm *message.StoreManager, mailbox string, count int) {
t.Helper()
metas, err := sm.GetMetadata(mailbox)
require.NoError(t, err, "StoreManager GetMetadata failed")
got := len(metas)
if got != count {
t.Errorf("Mailbox %q got %v messages, wanted %v", mailbox, got, count)
}
}

View File

@@ -3,44 +3,34 @@ package message
import (
"io"
"io/ioutil"
"net/mail"
"net/textproto"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/jhillyerd/enmime/v2"
)
// Metadata holds information about a message, but not the content.
type Metadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}
// Message holds both the metadata and content of a message.
type Message struct {
Metadata
event.MessageMetadata
env *enmime.Envelope
}
// New constructs a new Message
func New(m Metadata, e *enmime.Envelope) *Message {
func New(m event.MessageMetadata, e *enmime.Envelope) *Message {
return &Message{
Metadata: m,
env: e,
MessageMetadata: m,
env: e,
}
}
// Attachments returns the MIME attachments for the message.
func (m *Message) Attachments() []*enmime.Part {
return m.env.Attachments
attachments := append([]*enmime.Part{}, m.env.Inlines...)
attachments = append(attachments, m.env.Attachments...)
return attachments
}
// Header returns the header map for this message.
@@ -65,7 +55,7 @@ func (m *Message) Text() string {
// Delivery is used to add a message to storage.
type Delivery struct {
Meta Metadata
Meta event.MessageMetadata
Reader io.Reader
}
@@ -108,7 +98,7 @@ func (d *Delivery) Size() int64 {
// Source contains the raw content of the message.
func (d *Delivery) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(d.Reader), nil
return io.NopCloser(d.Reader), nil
}
// Seen getter.

View File

@@ -3,26 +3,19 @@ package msghub
import (
"container/ring"
"context"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/rs/zerolog/log"
)
// Length of msghub operation queue
const opChanLen = 100
// Message contains the basic header data for a message
type Message struct {
Mailbox string
ID string
From string
To []string
Subject string
Date time.Time
Size int64
}
// Listener receives the contents of the history buffer, followed by new messages
type Listener interface {
Receive(msg Message) error
Receive(msg event.MessageMetadata) error
Delete(mailbox string, id string) error
}
// Hub relays messages on to its listeners
@@ -36,38 +29,51 @@ type Hub struct {
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
// context is canceled.
func New(ctx context.Context, historyLen int) *Hub {
h := &Hub{
func New(historyLen int, extHost *extension.Host) *Hub {
hub := &Hub{
history: ring.New(historyLen),
listeners: make(map[Listener]struct{}),
opChan: make(chan func(h *Hub), opChanLen),
}
go func() {
for {
select {
case <-ctx.Done():
// Shutdown
close(h.opChan)
return
case op := <-h.opChan:
op(h)
}
}
}()
// Register an extension event listener for MessageStored.
extHost.Events.AfterMessageStored.AddListener("msghub",
func(msg event.MessageMetadata) {
hub.Dispatch(msg)
})
return h
extHost.Events.AfterMessageDeleted.AddListener("msghub",
func(msg event.MessageMetadata) {
hub.Delete(msg.Mailbox, msg.ID)
})
return hub
}
// Start Hub processing loop.
func (hub *Hub) Start(ctx context.Context) {
for {
select {
case <-ctx.Done():
// Shutdown
close(hub.opChan)
return
case op := <-hub.opChan:
hub.runOp(op)
}
}
}
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
// history buffer and then relayed to all registered listeners.
func (hub *Hub) Dispatch(msg Message) {
func (hub *Hub) Dispatch(msg event.MessageMetadata) {
hub.opChan <- func(h *Hub) {
if h.history != nil {
// Add to history buffer
h.history.Value = msg
h.history = h.history.Next()
// Deliver message to all listeners, removing listeners if they return an error
// Relay event to all listeners, removing listeners if they return an error.
for l := range h.listeners {
if err := l.Receive(msg); err != nil {
delete(h.listeners, l)
@@ -77,13 +83,44 @@ func (hub *Hub) Dispatch(msg Message) {
}
}
// Delete removes the message from the history buffer and instructs listeners to do the same.
func (hub *Hub) Delete(mailbox string, id string) {
hub.opChan <- func(h *Hub) {
if h.history == nil {
return
}
// Locate and remove history entry.
p := h.history
end := p
for {
if next, ok := p.Next().Value.(event.MessageMetadata); ok {
if mailbox == next.Mailbox && id == next.ID {
p.Next().Value = nil
break
}
}
if p = p.Next(); p == end {
break
}
}
// Relay event to all listeners, removing listeners if they return an error.
for l := range h.listeners {
if err := l.Delete(mailbox, id); err != nil {
delete(h.listeners, l)
}
}
}
}
// AddListener registers a listener to receive broadcasted messages.
func (hub *Hub) AddListener(l Listener) {
hub.opChan <- func(h *Hub) {
// Playback log
h.history.Do(func(v interface{}) {
if v != nil {
l.Receive(v.(Message))
_ = l.Receive(v.(event.MessageMetadata))
}
})
@@ -103,8 +140,22 @@ func (hub *Hub) RemoveListener(l Listener) {
// for unit tests.
func (hub *Hub) Sync() {
done := make(chan struct{})
hub.opChan <- func(h *Hub) {
hub.opChan <- func(_ *Hub) {
close(done)
}
<-done
}
func (hub *Hub) runOp(op func(*Hub)) {
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok {
log.Error().Str("module", "msghub").Err(err).Msg("Operation panicked")
} else {
log.Error().Str("module", "msghub").Err(err).Msgf("Operation panicked: %s", r)
}
}
}()
op(hub)
}

View File

@@ -2,16 +2,25 @@ package msghub
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testListener implements the Listener interface, mock for unit tests
type testListener struct {
messages []*Message // received messages
wantMessages int // how many messages this listener wants to receive
errorAfter int // when != 0, messages until Receive() begins returning error
messages []*event.MessageMetadata // received messages
deletes []string // received deletes
wantEvents int // how many events this listener wants to receive
errorAfter int // when != 0, event count until Receive() begins returning error
gotEvents int
done chan struct{} // closed once we have received wantMessages
overflow chan struct{} // closed if we receive wantMessages+1
@@ -19,10 +28,11 @@ type testListener struct {
func newTestListener(want int) *testListener {
l := &testListener{
messages: make([]*Message, 0, want*2),
wantMessages: want,
done: make(chan struct{}),
overflow: make(chan struct{}),
messages: make([]*event.MessageMetadata, 0, want*2),
deletes: make([]string, 0, want*2),
wantEvents: want,
done: make(chan struct{}),
overflow: make(chan struct{}),
}
if want == 0 {
close(l.done)
@@ -32,29 +42,34 @@ func newTestListener(want int) *testListener {
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
// if instructed
func (l *testListener) Receive(msg Message) error {
func (l *testListener) Receive(msg event.MessageMetadata) error {
l.gotEvents++
l.messages = append(l.messages, &msg)
if len(l.messages) == l.wantMessages {
if l.gotEvents == l.wantEvents {
close(l.done)
}
if len(l.messages) == l.wantMessages+1 {
if l.gotEvents == l.wantEvents+1 {
close(l.overflow)
}
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
return fmt.Errorf("Too many messages")
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
return errors.New("too many messages")
}
return nil
}
func (l *testListener) Delete(mailbox string, id string) error {
l.gotEvents++
l.deletes = append(l.deletes, mailbox+"/"+id)
return nil
}
// String formats the got vs wanted message counts
func (l *testListener) String() string {
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantEvents)
}
func TestHubNew(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
hub := New(5, extension.NewHost())
if hub == nil {
t.Fatal("New() == nil, expected a new Hub")
}
@@ -63,30 +78,33 @@ func TestHubNew(t *testing.T) {
func TestHubZeroLen(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 0)
m := Message{}
for i := 0; i < 100; i++ {
hub := New(0, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for range 100 {
hub.Dispatch(m)
}
// Just making sure Hub doesn't panic
// Ensures Hub doesn't panic
}
func TestHubZeroListeners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
for i := 0; i < 100; i++ {
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for range 100 {
hub.Dispatch(m)
}
// Just making sure Hub doesn't panic
// Ensures Hub doesn't panic
}
func TestHubOneListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1)
hub.AddListener(l)
@@ -103,8 +121,9 @@ func TestHubOneListener(t *testing.T) {
func TestHubRemoveListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1)
hub.AddListener(l)
@@ -125,8 +144,9 @@ func TestHubRemoveListener(t *testing.T) {
func TestHubRemoveListenerOnError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
// error after 1 means listener should receive 2 messages before being removed
l := newTestListener(2)
@@ -151,14 +171,15 @@ func TestHubRemoveListenerOnError(t *testing.T) {
func TestHubHistoryReplay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 100)
hub := New(100, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(3)
hub.AddListener(l1)
// Broadcast 3 messages with no listeners
msgs := make([]Message, 3)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
msgs := make([]event.MessageMetadata, 3)
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
@@ -182,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 {
@@ -191,17 +212,67 @@ func TestHubHistoryReplay(t *testing.T) {
}
}
func TestHubHistoryDelete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(100, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(3)
hub.AddListener(l1)
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "hub",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
hub.Delete("hub", "1") // Delete a message
hub.Delete("zzz", "0") // Attempt to delete non-existent mailbox message
// Add a new listener, waits for 2 messages
l2 := newTestListener(2)
hub.AddListener(l2)
// Wait for messages (history)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
want := []string{"subj 0", "subj 2"}
for i := range want {
got := l2.messages[i].Subject
if got != want[i] {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
}
}
}
func TestHubHistoryReplayWrap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
hub := New(5, extension.NewHost())
go hub.Start(ctx)
l1 := newTestListener(20)
hub.AddListener(l1)
// Broadcast more messages than the hub can hold
msgs := make([]Message, 20)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
msgs := make([]event.MessageMetadata, 20)
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
@@ -225,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 {
@@ -234,10 +305,64 @@ func TestHubHistoryReplayWrap(t *testing.T) {
}
}
func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
bufferSize := 5
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(bufferSize, extension.NewHost())
go hub.Start(ctx)
waitForMessages := func(n int) {
l := newTestListener(n)
hub.AddListener(l)
select {
case <-l.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l)
}
}
// Broadcast more messages than the hub can hold.
msgs := make([]event.MessageMetadata, 10)
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "first",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
waitForMessages(bufferSize)
// Buffer must be configured size.
require.Equal(t, bufferSize, hub.history.Len())
// Delete a message still present in buffer.
hub.Delete("first", "7")
// Broadcast another set of messages.
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "second",
ID: strconv.Itoa(i),
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
waitForMessages(bufferSize)
// Ensure the buffer did not shrink after delete.
got := hub.history.Len()
assert.Equal(t, bufferSize, got, "got buffer size %d, wanted %d", got, bufferSize)
}
func TestHubContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
hub := New(ctx, 5)
m := Message{}
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
l := newTestListener(1)
hub.AddListener(l)

View File

@@ -2,12 +2,14 @@ package policy
import (
"bytes"
"errors"
"fmt"
"net"
"net/mail"
"strings"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
)
// Addressing handles email address policy.
@@ -17,30 +19,41 @@ type Addressing struct {
// ExtractMailbox extracts the mailbox name from a partial email address.
func (a *Addressing) ExtractMailbox(address string) (string, error) {
if a.Config.MailboxNaming == config.DomainNaming {
return extractDomainMailbox(address)
}
local, domain, err := parseEmailAddress(address)
if err != nil {
return "", err
}
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
if a.Config.MailboxNaming == config.LocalNaming {
return local, nil
}
if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
return "", fmt.Errorf("unknown MailboxNaming value: %v", a.Config.MailboxNaming)
}
if domain == "" {
return local, nil
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
return "", fmt.Errorf("domain part %q in %q failed validation", domain, address)
}
return local + "@" + domain, nil
}
// NewRecipient parses an address into a Recipient.
// NewRecipient parses an address into a Recipient. This is used for parsing RCPT TO arguments,
// not To headers.
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
@@ -50,12 +63,8 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
if err != nil {
return nil, err
}
ar, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
return &Recipient{
Address: *ar,
Address: mail.Address{Address: address},
addrPolicy: a,
LocalPart: local,
Domain: domain,
@@ -63,6 +72,26 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
}, nil
}
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
// not To headers.
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
if address == "" {
return &Origin{
addrPolicy: a,
}, nil
}
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
}
return &Origin{
Address: mail.Address{Address: address},
addrPolicy: a,
LocalPart: local,
Domain: domain,
}, nil
}
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
domain = strings.ToLower(domain)
@@ -91,6 +120,19 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool {
return false
}
// ShouldAcceptOriginDomain indicates if Inbucket accept mail from the specified domain.
func (a *Addressing) ShouldAcceptOriginDomain(domain string) bool {
domain = strings.ToLower(domain)
if len(a.Config.SMTP.RejectOriginDomains) > 0 {
for _, d := range a.Config.SMTP.RejectOriginDomains {
if stringutil.MatchWithWildcards(d, domain) {
return false
}
}
}
return true
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696.
@@ -100,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
}
@@ -108,13 +150,24 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
// ParseEmailAddress().
func ValidateDomainPart(domain string) bool {
if len(domain) == 0 {
ln := len(domain)
if ln == 0 {
return false
}
if len(domain) > 255 {
if ln > 255 {
return false
}
if domain[len(domain)-1] != '.' {
if ln >= 4 && domain[0] == '[' && domain[ln-1] == ']' {
// Bracketed domains must contain an IP address.
s := 1
if strings.HasPrefix(domain[1:], "IPv6:") {
s = 6
}
ip := net.ParseIP(domain[s : ln-1])
return ip != nil
}
if domain[ln-1] != '.' {
domain += "."
}
prev := '.'
@@ -128,8 +181,8 @@ func ValidateDomainPart(domain string) bool {
hasAlphaNum = true
labelLen++
case c == '-':
if prev == '.' {
// Cannot lead with hyphen.
if prev == '.' || prev == '-' {
// Cannot lead with hyphen or double hyphen.
return false
}
case c == '.':
@@ -154,29 +207,74 @@ func ValidateDomainPart(domain string) bool {
return true
}
// Extracts the mailbox name when domain addressing is enabled.
func extractDomainMailbox(address string) (string, error) {
var local, domain string
var err error
if address != "" && address[0] == '[' && address[len(address)-1] == ']' {
// Likely an IP address in brackets, treat as domain only.
domain = address
} else {
local, domain, err = parseEmailAddress(address)
if err != nil {
return "", err
}
}
if local != "" {
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
}
// If no @domain is specified, assume this is being used for mailbox lookup via the API.
if domain == "" {
domain = local
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("domain part %q in %q failed validation", domain, address)
}
return domain, nil
}
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
// error is returned if the local part fails validation following the guidelines in RFC3696. The
// domain part is optional and not validated.
func parseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", fmt.Errorf("Empty address")
return "", "", errors.New("empty address")
}
if len(address) > 320 {
return "", "", fmt.Errorf("Address exceeds 320 characters")
return "", "", errors.New("address exceeds 320 characters")
}
// Remove forward-path routes.
if address[0] == '@' {
return "", "", fmt.Errorf("Address cannot start with @ symbol")
end := strings.IndexRune(address, ':')
if end == -1 {
return "", "", errors.New("missing terminating ':' in route specification")
}
address = address[end+1:]
if address == "" {
return "", "", errors.New("address empty after removing route specification")
}
}
if address[0] == '.' {
return "", "", fmt.Errorf("Address cannot start with a period")
return "", "", errors.New("address cannot start with a period")
}
// Loop over address parsing out local part.
buf := new(bytes.Buffer)
prev := byte('.')
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'):
@@ -193,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 {
@@ -204,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 {
@@ -214,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 == '@':
@@ -239,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)
@@ -257,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
}
@@ -277,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

@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/policy"
)
func TestShouldAcceptDomain(t *testing.T) {
@@ -33,7 +33,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with default reject.
@@ -60,7 +59,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -90,7 +88,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with storage disabled.
@@ -117,7 +114,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -125,134 +121,218 @@ func TestShouldStoreDomain(t *testing.T) {
func TestExtractMailboxValid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
testTable := []struct {
input string // Input to test
local string // Expected output when mailbox naming = local
full string // Expected output when mailbox naming = full
input string // Input to test
local string // Expected output when mailbox naming = local
full string // Expected output when mailbox naming = full
domain string // Expected output when mailbox naming = domain
}{
{
input: "mailbox",
local: "mailbox",
full: "mailbox",
input: "mailbox",
local: "mailbox",
full: "mailbox",
domain: "mailbox",
},
{
input: "user123",
local: "user123",
full: "user123",
input: "user123",
local: "user123",
full: "user123",
domain: "user123",
},
{
input: "MailBOX",
local: "mailbox",
full: "mailbox",
input: "MailBOX",
local: "mailbox",
full: "mailbox",
domain: "mailbox",
},
{
input: "First.Last",
local: "first.last",
full: "first.last",
input: "First.Last",
local: "first.last",
full: "first.last",
domain: "first.last",
},
{
input: "user+label",
local: "user",
full: "user",
input: "user+label",
local: "user",
full: "user",
domain: "user",
},
{
input: "chars!#$%",
local: "chars!#$%",
full: "chars!#$%",
input: "chars!#$%",
local: "chars!#$%",
full: "chars!#$%",
domain: "",
},
{
input: "chars&'*-",
local: "chars&'*-",
full: "chars&'*-",
input: "chars&'*-",
local: "chars&'*-",
full: "chars&'*-",
domain: "",
},
{
input: "chars=/?^",
local: "chars=/?^",
full: "chars=/?^",
input: "chars=/?^",
local: "chars=/?^",
full: "chars=/?^",
domain: "",
},
{
input: "chars_`.{",
local: "chars_`.{",
full: "chars_`.{",
input: "chars_`.{",
local: "chars_`.{",
full: "chars_`.{",
domain: "",
},
{
input: "chars|}~",
local: "chars|}~",
full: "chars|}~",
input: "chars|}~",
local: "chars|}~",
full: "chars|}~",
domain: "",
},
{
input: "mailbox@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
input: "mailbox@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
domain: "domain.com",
},
{
input: "user123@domain.com",
local: "user123",
full: "user123@domain.com",
input: "user123@domain.com",
local: "user123",
full: "user123@domain.com",
domain: "domain.com",
},
{
input: "MailBOX@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
input: "MailBOX@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
domain: "domain.com",
},
{
input: "First.Last@domain.com",
local: "first.last",
full: "first.last@domain.com",
input: "First.Last@domain.com",
local: "first.last",
full: "first.last@domain.com",
domain: "domain.com",
},
{
input: "user+label@domain.com",
local: "user",
full: "user@domain.com",
input: "user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "chars!#$%@domain.com",
local: "chars!#$%",
full: "chars!#$%@domain.com",
input: "chars!#$%@domain.com",
local: "chars!#$%",
full: "chars!#$%@domain.com",
domain: "domain.com",
},
{
input: "chars&'*-@domain.com",
local: "chars&'*-",
full: "chars&'*-@domain.com",
input: "chars&'*-@domain.com",
local: "chars&'*-",
full: "chars&'*-@domain.com",
domain: "domain.com",
},
{
input: "chars=/?^@domain.com",
local: "chars=/?^",
full: "chars=/?^@domain.com",
input: "chars=/?^@domain.com",
local: "chars=/?^",
full: "chars=/?^@domain.com",
domain: "domain.com",
},
{
input: "chars_`.{@domain.com",
local: "chars_`.{",
full: "chars_`.{@domain.com",
input: "chars_`.{@domain.com",
local: "chars_`.{",
full: "chars_`.{@domain.com",
domain: "domain.com",
},
{
input: "chars|}~@domain.com",
local: "chars|}~",
full: "chars|}~@domain.com",
input: "chars|}~@domain.com",
local: "chars|}~",
full: "chars|}~@domain.com",
domain: "domain.com",
},
{
input: "chars|}~@example.co.uk",
local: "chars|}~",
full: "chars|}~@example.co.uk",
domain: "example.co.uk",
},
{
input: "@host:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "@a.com,@b.com:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "u@[127.0.0.1]",
local: "u",
full: "u@[127.0.0.1]",
domain: "[127.0.0.1]",
},
{
input: "u@[IPv6:2001:db8:aaaa:1::100]",
local: "u",
full: "u@[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
}
for _, tc := range testTable {
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)
}
}
}
// Test special cases with domain addressing mode.
func TestExtractDomainMailboxValid(t *testing.T) {
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
tests := map[string]struct {
input string // Input to test
domain string // Expected output when mailbox naming = domain
}{
"ipv4": {
input: "[127.0.0.1]",
domain: "[127.0.0.1]",
},
"medium ipv6": {
input: "[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
} else if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
})
}
}
func TestExtractMailboxInvalid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
// Test local mailbox naming policy.
localInvalidTable := []struct {
input, msg string
@@ -267,6 +347,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
}
}
// Test full mailbox naming policy.
fullInvalidTable := []struct {
input, msg string
@@ -282,6 +363,29 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
}
}
// Test domain mailbox naming policy.
domainInvalidTable := []struct {
input, msg string
}{
{"", "Empty mailbox name is not permitted"},
{"user@host@domain.com", "@ symbol not permitted"},
{"first.last@dom ain.com", "Space not permitted"},
{"first\"last@domain.com", "Double quote not permitted"},
{"first\nlast@domain.com", "Control chars not permitted"},
{"first.last@chars!#$%.com", "Invalid domain name"},
{"first.last@.example.com", "Domain cannot start with dot"},
{"first.last@-example.com", "Domain canont start with dash"},
{"first.last@example.com-", "Domain cannot end with dash"},
{"first.last@example..com", "Domain cannot contain double dots"},
{"first.last@example--com", "Domain cannot contain double dashes"},
{"first.last@example.-com", "Domain cannot contain concecutive symbols"},
}
for _, tt := range domainInvalidTable {
if _, err := domainPolicy.ExtractMailbox(tt.input); err == nil {
t.Errorf("Didn't get an error while parsing in domain mode %q: %v", tt.input, tt.msg)
}
}
}
func TestValidateDomain(t *testing.T) {
@@ -307,6 +411,10 @@ func TestValidateDomain(t *testing.T) {
{strings.Repeat("a", 256), false, "Max domain length is 255"},
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
{"[0.0.0.0]", true, "Single digit octet IP addr is valid"},
{"[123.123.123.123]", true, "Multiple digit octet IP addr is valid"},
{"[IPv6:2001:0db8:aaaa:0001:0000:0000:0000:0200]", true, "Full IPv6 addr is valid"},
{"[IPv6:::1]", true, "Abbr IPv6 addr is valid"},
}
for _, tt := range testTable {
if policy.ValidateDomainPart(tt.input) != tt.expect {
@@ -361,6 +469,9 @@ func TestValidateLocal(t *testing.T) {
{"$A12345", true, "RFC3696 test case should be valid"},
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
{"_somename", true, "RFC3696 test case should be valid"},
{"@host:mailbox", true, "Forward-path routes are valid"},
{"@a.com,@b.com:mailbox", true, "Multi-hop forward-path routes are valid"},
{"@a.com,mailbox", false, "Unterminated forward-path routes are invalid"},
}
for _, tt := range testTable {
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
@@ -372,3 +483,33 @@ func TestValidateLocal(t *testing.T) {
}
}
}
// TestRecipientAddress verifies the Recipient.Address values returned by Addressing.NewRecipient.
// This function parses a RCPT TO path, not a To header. See rfc5321#section-4.1.2
func TestRecipientAddress(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
tests := map[string]string{
"common": "user@example.com",
"with label": "user+mailbox@example.com",
"special chars": "a!#$%&'*+-/=?^_`{|}~@example.com",
"ipv4": "user@[127.0.0.1]",
"ipv6": "user@[IPv6:::1]",
"route host": "@host:user@example.com",
"route domain": "@route.com:user@example.com",
"multi-hop route": "@first.com,@second.com:user@example.com",
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
r, err := localPolicy.NewRecipient(tc)
if err != nil {
t.Fatalf("Parse of %q failed: %v", tc, err)
}
if got, want := r.Address.Address, tc; got != want {
t.Errorf("Got Address: %q, want: %q", got, want)
}
})
}
}

20
pkg/policy/origin.go Normal file
View File

@@ -0,0 +1,20 @@
package policy
import (
"net/mail"
)
// Origin represents a potential email origin, allows policies for it to be queried.
type Origin struct {
mail.Address
addrPolicy *Addressing
// LocalPart is the part of the address before @, including +extension.
LocalPart string
// Domain is the part of the address after @.
Domain string
}
// ShouldAccept returns true if Inbucket should accept mail from this origin.
func (o *Origin) ShouldAccept() bool {
return o.addrPolicy.ShouldAcceptOriginDomain(o.Domain)
}

View File

@@ -10,10 +10,10 @@ import (
"encoding/json"
"strconv"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
@@ -26,19 +26,20 @@ 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 {
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
}
}
return web.RenderJSON(w, jmessages)
@@ -64,28 +65,30 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
for i, part := range attachParts {
content := part.Content
var checksum = md5.Sum(content)
// Example URL: http://localhost/serve/mailbox/swaks/0001/attach/0/favicon.png
link := "http://" + req.Host + "/serve/mailbox/" + name + "/" + id + "/attach/" +
strconv.Itoa(i) + "/" + part.FileName
checksum := md5.Sum(content)
attachments[i] = &model.JSONMessageAttachmentV1{
ContentType: part.ContentType,
FileName: part.FileName,
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
MD5: hex.EncodeToString(checksum[:]),
ContentType: part.ContentType,
FileName: part.FileName,
DownloadLink: link,
ViewLink: link,
MD5: hex.EncodeToString(checksum[:]),
}
}
return web.RenderJSON(w,
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Body: &model.JSONMessageBodyV1{
Text: msg.Text(),
HTML: msg.HTML(),
@@ -105,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

@@ -9,24 +9,10 @@ import (
"testing"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/test"
)
const (
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
headerKey = "header"
bodyKey = "body"
textKey = "text"
htmlKey = "html"
"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/v2"
)
func TestRestMailboxList(t *testing.T) {
@@ -67,7 +53,7 @@ func TestRestMailboxList(t *testing.T) {
// Test JSON message headers
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{
meta1 := event.MessageMetadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -75,7 +61,7 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := message.Metadata{
meta2 := event.MessageMetadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
@@ -83,8 +69,8 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
// Check return code
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
@@ -112,6 +98,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[0]/subject", "subject 1")
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
decodedNumberEquals(t, result, "[0]/size", 0)
decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/mailbox", "good")
@@ -120,6 +107,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[1]/subject", "subject 2")
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
decodedNumberEquals(t, result, "[1]/size", 0)
decodedBoolEquals(t, result, "[1]/seen", false)
@@ -176,7 +164,7 @@ func TestRestMessage(t *testing.T) {
// Test JSON message headers
tzPST := time.FixedZone("PST", -8*3600)
msg1 := message.New(
message.Metadata{
event.MessageMetadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -194,6 +182,14 @@ func TestRestMessage(t *testing.T) {
"From": []string{"noreply@inbucket.org"},
},
},
Attachments: []*enmime.Part{{
FileName: "favicon.png",
ContentType: "image/png",
}},
Inlines: []*enmime.Part{{
FileName: "statement.pdf",
ContentType: "application/pdf",
}},
},
)
mm.AddMessage("good", msg1)
@@ -221,6 +217,7 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
decodedStringEquals(t, result, "subject", "subject 1")
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
decodedNumberEquals(t, result, "size", 0)
decodedBoolEquals(t, result, "seen", true)
decodedStringEquals(t, result, "body/text", "This is some text")
@@ -228,6 +225,14 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
decodedStringEquals(t, result, "attachments/[0]/filename", "statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/content-type", "application/pdf")
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[1]/filename", "favicon.png")
decodedStringEquals(t, result, "attachments/[1]/content-type", "image/png")
decodedStringEquals(t, result, "attachments/[1]/download-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
decodedStringEquals(t, result, "attachments/[1]/view-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
if t.Failed() {
// Wait for handler to finish logging
@@ -243,7 +248,7 @@ func TestRestMarkSeen(t *testing.T) {
// Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{
meta1 := event.MessageMetadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -251,7 +256,7 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := message.Metadata{
meta2 := event.MessageMetadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
@@ -259,8 +264,8 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
// Mark one read.
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
expectCode := 200

View File

@@ -3,12 +3,12 @@ package client
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1
@@ -18,15 +18,22 @@ type Client struct {
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
// "http://localhost:9000"
func New(baseURL string) (*Client, error) {
func New(baseURL string, opts ...Option) (*Client, error) {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
mergedOpts := getDefaultOptions()
for _, opt := range opts {
opt.apply(mergedOpts)
}
c := &Client{
restClient{
client: &http.Client{
Timeout: 30 * time.Second,
Timeout: mergedOpts.timeout,
Transport: mergedOpts.transport,
},
baseURL: parsedURL,
},
@@ -35,33 +42,56 @@ func New(baseURL string) (*Client, error) {
}
// ListMailbox returns a list of messages for the requested mailbox
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
func (c *Client) ListMailbox(name string) ([]*MessageHeader, error) {
return c.ListMailboxWithContext(context.Background(), name)
}
// ListMailboxWithContext returns a list of messages for the requested mailbox
func (c *Client) ListMailboxWithContext(ctx context.Context, name string) ([]*MessageHeader, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
err = c.doJSON("GET", uri, &headers)
headers := make([]*MessageHeader, 0, 32)
err := c.doJSON(ctx, "GET", uri, &headers)
if err != nil {
return nil, err
}
// Add Client ref to each MessageHeader for convenience funcs.
for _, h := range headers {
h.client = c
}
return
return headers, nil
}
// GetMessage returns the message details given a mailbox name and message ID.
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
return c.GetMessageWithContext(context.Background(), name, id)
}
// GetMessageWithContext returns the message details given a mailbox name and message ID.
func (c *Client) GetMessageWithContext(ctx context.Context, name, id string) (*Message, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err = c.doJSON("GET", uri, &message)
var message Message
err := c.doJSON(ctx, "GET", uri, &message)
if err != nil {
return nil, err
}
message.client = c
return
return &message, nil
}
// MarkSeen marks the specified message as having been read.
func (c *Client) MarkSeen(name, id string) error {
return c.MarkSeenWithContext(context.Background(), name, id)
}
// MarkSeenWithContext marks the specified message as having been read.
func (c *Client) MarkSeenWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err := c.doJSON("PATCH", uri, nil)
err := c.doJSON(ctx, "PATCH", uri, nil)
if err != nil {
return err
}
@@ -70,19 +100,25 @@ func (c *Client) MarkSeen(name, id string) error {
// GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
return c.GetMessageSourceWithContext(context.Background(), name, id)
}
// GetMessageSourceWithContext returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSourceWithContext(ctx context.Context, name, id string) (*bytes.Buffer, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
resp, err := c.do("GET", uri, nil)
resp, err := c.do(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil,
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
return buf, err
@@ -90,29 +126,43 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
return c.DeleteMessageWithContext(context.Background(), name, id)
}
// DeleteMessageWithContext deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessageWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error {
return c.PurgeMailboxWithContext(context.Background(), name)
}
// PurgeMailboxWithContext deletes all messages in the given mailbox
func (c *Client) PurgeMailboxWithContext(ctx context.Context, name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
@@ -124,17 +174,32 @@ type MessageHeader struct {
// GetMessage returns this message with content
func (h *MessageHeader) GetMessage() (message *Message, err error) {
return h.client.GetMessage(h.Mailbox, h.ID)
return h.GetMessageWithContext(context.Background())
}
// GetMessageWithContext returns this message with content
func (h *MessageHeader) GetMessageWithContext(ctx context.Context) (message *Message, err error) {
return h.client.GetMessageWithContext(ctx, h.Mailbox, h.ID)
}
// GetSource returns the source for this message
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
return h.client.GetMessageSource(h.Mailbox, h.ID)
return h.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (h *MessageHeader) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return h.client.GetMessageSourceWithContext(ctx, h.Mailbox, h.ID)
}
// Delete deletes this message from the mailbox
func (h *MessageHeader) Delete() error {
return h.client.DeleteMessage(h.Mailbox, h.ID)
return h.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (h *MessageHeader) DeleteWithContext(ctx context.Context) error {
return h.client.DeleteMessageWithContext(ctx, h.Mailbox, h.ID)
}
// Message represents an Inbucket message including content
@@ -145,10 +210,20 @@ type Message struct {
// GetSource returns the source for this message
func (m *Message) GetSource() (*bytes.Buffer, error) {
return m.client.GetMessageSource(m.Mailbox, m.ID)
return m.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (m *Message) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return m.client.GetMessageSourceWithContext(ctx, m.Mailbox, m.ID)
}
// Delete deletes this message from the mailbox
func (m *Message) Delete() error {
return m.client.DeleteMessage(m.Mailbox, m.ID)
return m.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (m *Message) DeleteWithContext(ctx context.Context) error {
return m.client.DeleteMessageWithContext(ctx, m.Mailbox, m.ID)
}

View File

@@ -0,0 +1,39 @@
package client
import (
"net/http"
"time"
)
// options is a struct that holds the options for the rest client
type options struct {
transport http.RoundTripper
timeout time.Duration
}
// Option can apply itself to the private options type.
type Option interface {
apply(opts *options)
}
func getDefaultOptions() *options {
return &options{
timeout: 30 * time.Second,
}
}
type transportOption struct {
transport http.RoundTripper
}
func (t transportOption) apply(opts *options) {
opts.transport = t.transport
}
// WithTransport sets the transport for the rest client.
// Transport specifies the mechanism by which individual
// HTTP requests are made.
// If nil, http.DefaultTransport is used.
func WithTransport(transport http.RoundTripper) Option {
return transportOption{transport}
}

View File

@@ -1,13 +1,16 @@
package client_test
import (
"github.com/gorilla/mux"
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
func TestClientV1ListMailbox(t *testing.T) {
@@ -209,6 +212,33 @@ func TestClientV1GetMessageSource(t *testing.T) {
}
}
func TestClientV1WithCustomTransport(t *testing.T) {
// Call setup, passing a custom roundtripper and make sure it was used during the request.
mockRoundTripper := &mockRoundTripper{ResponseBody: "Custom Transport"}
c, router, teardown := setup(client.WithTransport(mockRoundTripper))
defer teardown()
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
Handler(&jsonHandler{json: `message source`})
// Method under test.
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want := mockRoundTripper.ResponseBody
got := source.String()
if got != want {
t.Errorf("Source got %q, want %q", got, want)
}
if mockRoundTripper.CallCount != 1 {
t.Errorf("RoundTripper called %v times, want 1", mockRoundTripper.CallCount)
}
}
func TestClientV1DeleteMessage(t *testing.T) {
// Setup.
c, router, teardown := setup()
@@ -336,11 +366,24 @@ func TestClientV1MessageHeader(t *testing.T) {
}
}
type mockRoundTripper struct {
ResponseBody string
CallCount int
}
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
m.CallCount++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(m.ResponseBody)),
}, nil
}
// setup returns a client, router and server for API testing.
func setup() (c *client.Client, router *mux.Router, teardown func()) {
func setup(opts ...client.Option) (c *client.Client, router *mux.Router, teardown func()) {
router = mux.NewRouter()
server := httptest.NewServer(router)
c, err := client.New(server.URL)
c, err := client.New(server.URL, opts...)
if err != nil {
panic(err)
}
@@ -357,5 +400,5 @@ type jsonHandler struct {
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
j.called = true
w.Write([]byte(j.json))
_, _ = w.Write([]byte(j.json))
}

View File

@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
// Example demonstrates basic usage for the Inbucket REST client.
@@ -16,34 +16,42 @@ func Example() {
baseURL, teardown := exampleSetup()
defer teardown()
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
log.Fatal(err)
}
err := func() error {
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
return err
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
log.Fatal(err)
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
return err
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
log.Fatal(err)
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
return err
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Delete the second message.
err = headers[1].Delete()
if err != nil {
return err
}
return nil
}()
// Delete the second message.
err = headers[1].Delete()
if err != nil {
log.Fatal(err)
log.Print(err)
}
// Output:
@@ -62,7 +70,7 @@ func exampleSetup() (baseURL string, teardown func()) {
// Handle ListMailbox request.
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[
_, _ = w.Write([]byte(`[
{
"mailbox": "user1",
"id": "20180107T224128-0000",
@@ -79,7 +87,7 @@ func exampleSetup() (baseURL string, teardown func()) {
// Handle GetMessage request.
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{
_, _ = w.Write([]byte(`{
"mailbox": "user1",
"id": "20180107T224128-0000",
"from": "admin@inbucket.org",

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, err
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
}
@@ -56,26 +55,5 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
resp, err := c.do(method, uri, body)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}

View File

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

View File

@@ -4,19 +4,20 @@ import (
"time"
)
// JSONMessageHeaderV1 contains the basic header data for a message
// JSONMessageHeaderV1 contains the basic header data for a message.
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
type JSONMessageV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"`
@@ -31,7 +33,7 @@ type JSONMessageV1 struct {
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
// JSONMessageAttachmentV1 contains information about a MIME attachment
// JSONMessageAttachmentV1 contains information about a MIME attachment.
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`
@@ -40,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
MD5 string `json:"md5"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
// JSONMessageBodyV1 contains the Text and HTML versions of the message body.
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`

View File

@@ -0,0 +1,15 @@
package model
// JSONMessageIDV2 uniquely identifies a message.
type JSONMessageIDV2 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
}
// JSONMonitorEventV2 contains events for the Inbucket mailbox and monitor tabs.
type JSONMonitorEventV2 struct {
// Event variant: `message-deleted`, `message-stored`.
Variant string `json:"variant"`
Identifier *JSONMessageIDV2 `json:"identifier"`
Header *JSONMessageHeaderV1 `json:"header"`
}

View File

@@ -1,7 +1,9 @@
package rest
import "github.com/gorilla/mux"
import "github.com/jhillyerd/inbucket/pkg/server/web"
import (
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/server/web"
)
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
@@ -22,4 +24,10 @@ func SetupRoutes(r *mux.Router) {
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
// API v2
r.Path("/v2/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV2)).Name("MonitorAllMessagesV2").Methods("GET")
r.Path("/v2/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV2)).Name("MonitorMailboxMessagesV2").Methods("GET")
}

View File

@@ -5,71 +5,85 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
writeWaitV1 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
pingPeriodV1 = (pongWaitV1 * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
pongWaitV1 = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 512
maxMessageSizeV1 = 512
)
// options for gorilla connection upgrader
var upgrader = websocket.Upgrader{
var upgraderV1 = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListener handles messages from the msghub
type msgListener struct {
hub *msghub.Hub // Global message hub
c chan msghub.Message // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes
// msgListenerV1 handles messages from the msghub
type msgListenerV1 struct {
hub *msghub.Hub // Global message hub
c chan event.MessageMetadata // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes
}
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
// newMsgListenerV1 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
ml := &msgListener{
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListenerV1 {
ml := &msgListenerV1{
hub: hub,
c: make(chan msghub.Message, 100),
c: make(chan event.MessageMetadata, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message
func (ml *msgListener) Receive(msg msghub.Message) error {
// Receive handles an incoming message.
func (ml *msgListenerV1) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match mailbox name
// Did not match the watched mailbox name.
return nil
}
ml.c <- msg
return nil
}
// Delete handles a deleted message.
func (ml *msgListenerV1) Delete(mailbox string, id string) error {
// Deletes are ignored in socketv1 API.
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListener) WSReader(conn *websocket.Conn) {
func (ml *msgListenerV1) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close()
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetReadLimit(maxMessageSizeV1)
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to setup read deadline")
}
conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
conn.SetReadDeadline(time.Now().Add(pongWait))
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
}
return nil
})
@@ -92,8 +106,11 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
func (ml *msgListenerV1) WSWriter(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
ticker := time.NewTicker(pingPeriodV1)
defer func() {
ticker.Stop()
ml.Close()
@@ -103,40 +120,34 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
for {
select {
case msg, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
}
if !ok {
// msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{})
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
if conn.WriteJSON(metadataToHeader(&msg)) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
}
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
slog.Debug().Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListener) Close() {
func (ml *msgListenerV1) Close() {
select {
case <-ml.c:
// Already closed
@@ -151,7 +162,7 @@ func (ml *msgListener) Close() {
func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil)
conn, err := upgraderV1.Upgrade(w, req, nil)
if err != nil {
return err
}
@@ -163,7 +174,7 @@ func MonitorAllMessagesV1(
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, "")
ml := newMsgListenerV1(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
@@ -178,7 +189,7 @@ func MonitorMailboxMessagesV1(
return err
}
// Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil)
conn, err := upgraderV1.Upgrade(w, req, nil)
if err != nil {
return err
}
@@ -190,8 +201,21 @@ func MonitorMailboxMessagesV1(
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, name)
ml := newMsgListenerV1(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
func metadataToHeader(msg *event.MessageMetadata) *model.JSONMessageHeaderV1 {
return &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
}

View File

@@ -0,0 +1,225 @@
package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/rs/zerolog/log"
)
const (
// Time allowed to write a message to the peer.
writeWaitV2 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriodV2 = (pongWaitV2 * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWaitV2 = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSizeV2 = 512
)
// options for gorilla connection upgrader
var upgraderV2 = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListenerV2 handles messages from the msghub
type msgListenerV2 struct {
hub *msghub.Hub // Global message hub.
c chan *model.JSONMonitorEventV2 // Queue of incoming events.
mailbox string // Name of mailbox to monitor, "" == all mailboxes.
}
// newMsgListenerV2 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListenerV2 {
ml := &msgListenerV2{
hub: hub,
c: make(chan *model.JSONMonitorEventV2, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message.
func (ml *msgListenerV2) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match the watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-stored",
Header: metadataToHeader(&msg),
}
return nil
}
// Delete handles a deleted message.
func (ml *msgListenerV2) Delete(mailbox string, id string) error {
if ml.mailbox != "" && ml.mailbox != mailbox {
// Did not match watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-deleted",
Identifier: &model.JSONMessageIDV2{
Mailbox: mailbox,
ID: id,
},
}
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListenerV2) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close()
conn.SetReadLimit(maxMessageSizeV2)
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to setup read deadline")
}
conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
}
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
slog.Warn().Err(err).Msg("Socket error")
} else {
slog.Debug().Msg("Closing socket")
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListenerV2) WSWriter(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
ticker := time.NewTicker(pingPeriodV2)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case event, ok := <-ml.c:
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
}
if !ok {
// msgListener closed, exit
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if conn.WriteJSON(event) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
}
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
slog.Debug().Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListenerV2) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
}
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
// the client of all messages received.
func MonitorAllMessagesV2(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
// MonitorMailboxMessagesV2 is a web handler which upgrades the connection to a websocket and
// notifies the client of messages received by a particular mailbox.
func MonitorMailboxMessagesV2(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket.
conn, err := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}

View File

@@ -2,38 +2,52 @@ package rest
import (
"bytes"
"context"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/server/web"
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("GET", url, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(body))
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
@@ -48,9 +62,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
UIDir: "../ui",
},
}
shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.NewServer(cfg, mm, &msghub.Hub{})
return buf
}
@@ -79,12 +92,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(float64); ok {
got, ok := val.(float64)
if ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
path, val, val, int64(got), want, int64(want))
}
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
@@ -109,12 +124,11 @@ func decodedStringEquals(t *testing.T, json interface{}, path string, want strin
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
// brackets require the parent element to be a []interface{}.
//
// getDecodedPath(o, "users", "[1]", "name")
// getDecodedPath(o, "users", "[1]", "name")
//
// is equivalent to the JavaScript:
//
// o.users[1].name
//
// o.users[1].name
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if len(path) == 0 {
return o, ""
@@ -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, ""
}

131
pkg/server/lifecycle.go Normal file
View File

@@ -0,0 +1,131 @@
package server
import (
"context"
"sync"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/rest"
"github.com/inbucket/inbucket/v3/pkg/server/pop3"
"github.com/inbucket/inbucket/v3/pkg/server/smtp"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/webui"
)
// Services holds the configured services.
type Services struct {
MsgHub *msghub.Hub
POP3Server *pop3.Server
RetentionScanner *storage.RetentionScanner
SMTPServer *smtp.Server
WebServer *web.Server
ExtHost *extension.Host
LuaHost *luahost.Host
notify chan error // Combined notification for failed services.
ready *sync.WaitGroup // Tracks services that have not reported ready.
}
// FullAssembly wires up a complete Inbucket environment.
func FullAssembly(conf *config.Root) (*Services, error) {
// Configure extensions.
extHost := extension.NewHost()
luaHost, err := luahost.New(conf.Lua, extHost)
if err != nil && err != luahost.ErrNoScript {
return nil, err
}
// Configure storage.
store, err := storage.FromConfig(conf.Storage, extHost)
if err != nil {
return nil, err
}
addrPolicy := &policy.Addressing{Config: conf}
// Configure shared components.
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store)
// Configure routes and build HTTP server.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
webServer := web.NewServer(conf, mmanager, msgHub)
pop3Server, err := pop3.NewServer(conf.POP3, store)
if err != nil {
return nil, err
}
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
s := &Services{
MsgHub: msgHub,
RetentionScanner: retentionScanner,
POP3Server: pop3Server,
SMTPServer: smtpServer,
WebServer: webServer,
ExtHost: extHost,
LuaHost: luaHost,
ready: &sync.WaitGroup{},
}
s.setupNotify()
return s, nil
}
// Start all services, returns immediately. Callers may use Notify to detect failed services.
func (s *Services) Start(ctx context.Context, readyFunc func()) {
go s.MsgHub.Start(ctx)
go s.WebServer.Start(ctx, s.makeReadyFunc())
go s.SMTPServer.Start(ctx, s.makeReadyFunc())
go s.POP3Server.Start(ctx, s.makeReadyFunc())
go s.RetentionScanner.Start(ctx)
// Notify when all services report ready.
go func() {
s.ready.Wait()
readyFunc()
}()
}
// Notify returns a merged channel of the error notification channels of all fallible services,
// allowing the process to be shutdown if needed.
func (s *Services) Notify() <-chan error {
return s.notify
}
// setupNotify merges the error notification channels of all fallible services.
func (s *Services) setupNotify() {
c := make(chan error, 1)
s.notify = c
go func() {
// TODO: What level to log failure.
select {
case err := <-s.POP3Server.Notify():
c <- err
case err := <-s.SMTPServer.Notify():
c <- err
case err := <-s.WebServer.Notify():
c <- err
}
}()
}
// makeReadyFunc returns a function used to signal that a service is ready. The `Services.ready`
// wait group can then be used to await all services being ready.
func (s *Services) makeReadyFunc() func() {
s.ready.Add(1)
var once sync.Once
return func() {
once.Do(s.ready.Done)
}
}

View File

@@ -2,6 +2,8 @@ package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"net"
@@ -10,7 +12,7 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -53,6 +55,7 @@ var commands = map[string]bool{
"PASS": true,
"APOP": true,
"CAPA": true,
"STLS": true,
}
// Session defines an active POP3 session
@@ -99,14 +102,27 @@ 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)
connToClose := conn
if s.config.ForceTLS {
logger.Debug().Msg("Setting up TLS for ForceTLS")
tlsConn := tls.Server(conn, s.tlsConfig)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
conn = tlsConn
}
logger.Info().Msg("Starting POP3 session")
defer func() {
if err := conn.Close(); err != nil {
logger.Debug().Msg("closing at end of session")
// Closing the tlsConn hangs.
if err := connToClose.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
logger.Debug().Msg("End of session")
s.wg.Done()
}()
@@ -117,46 +133,48 @@ func (s *Server) startSession(id int, conn net.Conn) {
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
line, err := ssn.readLine()
ssn.logger.Debug().Msgf("read %s", line)
if err == nil {
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
cmd, arg := ssn.parseCmd(line)
// Commands we handle in any state
if cmd == "CAPA" {
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
ssn.send(".")
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("-ERR Syntax error, command garbled")
ssn.send(".")
continue
}
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(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 {
@@ -169,6 +187,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
@@ -188,11 +207,43 @@ 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")
s.logger.Debug().Msg("Quitting.")
s.enterState(QUIT)
case "STLS":
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")
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.")
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.tlsConfig)
if err := tlsConn.HandshakeContext(tlsCtx); err != nil {
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
s.ooSeq(cmd)
}
s.conn = tlsConn
s.reader = bufio.NewReader(tlsConn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.logger.Debug().Msgf("TLS set %v", *s.tlsState)
case "USER":
if len(args) > 0 {
s.user = args[0]
@@ -413,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")
@@ -581,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

@@ -0,0 +1,391 @@
package pop3
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/textproto"
"os"
"path"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
)
func TestNoTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, false, false)
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.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 does not include 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)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("TLS not enabled but received STLS.")
}
}
}
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.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 does not include 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.")
}
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("STLS failed: %s", reply)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c = textproto.NewConn(tlsConn)
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("CAPA failed: %s", reply)
}
_, 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) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, true)
pipe := setupPOPSession(t, server)
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c := textproto.NewConn(tlsConn)
defer func() {
_ = c.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 does not include 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)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("STLS in CAPA in forceTLS mode.")
}
}
}
// net.Pipe does not implement deadlines
type mockConn struct {
net.Conn
}
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Server {
t.Helper()
cfg := config.POP3{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
Debug: true,
ForceTLS: forceTLS,
}
if tls {
cert, privKey, err := generateCertificate(t)
if err != nil {
t.Fatalf("Failed to generate x.509 certificate; %v", err)
}
// we have to write these things into files.
cfg.TLSEnabled = true
td := t.TempDir()
certPath := path.Join(td, "cert.pem")
keyPath := path.Join(td, "key.pem")
if err := os.WriteFile(certPath, certToPem(cert), 0700); err != nil {
t.Fatalf("Failed to write cert PEM file; %v", err)
}
if err := os.WriteFile(keyPath, privKeyToPem(privKey), 0700); err != nil {
t.Fatalf("Failed to write privKey PEM file; %v", err)
}
cfg.TLSCert = certPath
cfg.TLSPrivKey = keyPath
}
s, err := NewServer(cfg, ds)
if err != nil {
t.Fatalf("Failed to create server: %v.", err)
}
return s
}
var sessionNum int
func setupPOPSession(t *testing.T, server *Server) net.Conn {
t.Helper()
serverConn, clientConn := net.Pipe()
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(context.Background(), sessionNum, &mockConn{serverConn})
return clientConn
}
func privKeyToPem(privkey *rsa.PrivateKey) []byte {
privkeyBytes := x509.MarshalPKCS1PrivateKey(privkey)
return pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privkeyBytes,
},
)
}
func certToPem(cert []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})
}
func generateCertificate(t *testing.T) ([]byte, *rsa.PrivateKey, error) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
t.Fatalf("Failed to generate key; %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "localhost.local",
},
DNSNames: []string{"localhost", "127.0.0.1", "inbucket.local"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageEmailProtection},
}
cert, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("certificate generation failed; %v", err)
}
return cert, priv, nil
}

View File

@@ -2,58 +2,82 @@ package pop3
import (
"context"
"crypto/tls"
"fmt"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog/log"
)
// Server defines an instance of the POP3 server.
type Server struct {
config config.POP3 // POP3 configuration.
store storage.Store // Mail store.
listener net.Listener // TCP listener.
globalShutdown chan bool // Inbucket shutdown signal.
wg *sync.WaitGroup // Waitgroup tracking sessions.
config config.POP3 // POP3 configuration.
store storage.Store // Mail store.
listener net.Listener // TCP listener.
wg *sync.WaitGroup // Waitgroup tracking sessions.
notify chan error // Notify on fatal error.
tlsConfig *tls.Config // TLS encryption configuration.
tlsState *tls.ConnectionState
}
// New creates a new Server struct.
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
return &Server{
config: pop3Config,
store: store,
globalShutdown: shutdownChan,
wg: new(sync.WaitGroup),
// NewServer creates a new, unstarted, POP3 server.
func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{}
if pop3Config.TLSEnabled {
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
return nil, fmt.Errorf("failed to configure TLS; %v", err)
// Do not silently turn off Security.
}
slog.Debug().Msg("TLS config available")
} else {
tlsConfig = nil
}
return &Server{
config: pop3Config,
store: store,
wg: new(sync.WaitGroup),
notify: make(chan error, 1),
tlsConfig: tlsConfig,
}, nil
}
// Start the server and listen for connections
func (s *Server) Start(ctx context.Context) {
func (s *Server) Start(ctx context.Context, readyFunc func()) {
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
// Listener go routine.
// Start listener go routine.
go s.serve(ctx)
readyFunc()
// Wait for shutdown.
select {
case _ = <-ctx.Done():
}
<-ctx.Done()
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit.
if err := s.listener.Close(); err != nil {
slog.Error().Err(err).Msg("Failed to close POP3 listener")
@@ -66,18 +90,18 @@ func (s *Server) serve(ctx context.Context) {
var tempDelay time.Duration
for sid := 1; ; sid++ {
if conn, err := s.listener.Accept(); err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again.
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
// Timeout, sleep for a bit and try again.
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
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 error; retrying in %v", tempDelay)
Msgf("POP3 accept timout; retrying in %v", tempDelay)
time.Sleep(tempDelay)
continue
} else {
@@ -88,30 +112,28 @@ func (s *Server) serve(ctx context.Context) {
return
default:
// Something went wrong.
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
}
} else {
tempDelay = 0
s.wg.Add(1)
go s.startSession(sid, conn)
go s.startSession(ctx, sid, conn)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("waiting for connections to complete.")
s.wg.Wait()
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
}
// Notify allows the running POP3 server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
}

View File

@@ -4,31 +4,47 @@ import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/mail"
"net/textproto"
"regexp"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our SMTP state machine.
type State int
const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
// `User Name`
usernameChallenge = "VXNlciBOYW1lAA=="
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
// `Password`
passwordChallenge = "UGFzc3dvcmQA"
)
const (
// GREET State: Waiting for HELO
GREET State = iota
// READY State: Got HELO, waiting for MAIL
READY
// LOGIN State: Got AUTH LOGIN command, expecting Username
LOGIN
// PASSWORD State: Got Username, expecting password
PASSWORD
// MAIL State: Got MAIL, accepting RCPTs
MAIL
// DATA State: Got DATA, waiting for "."
@@ -37,11 +53,11 @@ const (
QUIT
)
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( ([\w= ]|=<>)+)?$`)
func (s State) String() string {
switch s {
@@ -76,6 +92,7 @@ var commands = map[string]bool{
"QUIT": true,
"TURN": true,
"STARTTLS": true,
"AUTH": true,
}
// Session holds the state of an SMTP session
@@ -88,7 +105,7 @@ type Session struct {
sendError error // Last network send error.
state State // Session state machine.
reader *bufio.Reader // Buffered reading for TCP conn.
from string // Sender from MAIL command.
from *policy.Origin // Sender from MAIL command.
recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
@@ -99,40 +116,52 @@ 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())
return &Session{
remoteHost := conn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteHost); err == nil {
remoteHost = host
}
session := &Session{
Server: server,
id: id,
conn: conn,
state: GREET,
reader: reader,
remoteHost: host,
remoteHost: remoteHost,
recipients: make([]*policy.Recipient, 0),
logger: logger,
debug: server.config.Debug,
text: textproto.NewConn(conn),
}
if server.config.ForceTLS {
session.tlsState = new(tls.ConnectionState)
*session.tlsState = conn.(*tls.Conn).ConnectionState()
}
return session
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.Hook(logHook{}).With().
// Session flow:
// 1. Send initial greeting
// 2. Receive cmd
// 3. If good cmd, respond, optionally change state
// 4. If bad cmd, respond error
// 5. Goto 2
func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
logger = logger.Hook(logHook{}).With().
Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting SMTP session")
// Update WaitGroup and counters.
s.wg.Add(1)
expConnectsCurrent.Add(1)
expConnectsTotal.Add(1)
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
@@ -153,6 +182,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
line, err := ssn.readLine()
if err == nil {
// Handle LOGIN/PASSWORD states here, because they don't expect a command.
switch ssn.state {
case LOGIN:
ssn.loginHandler()
continue
case PASSWORD:
ssn.passwordHandler()
continue
}
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
@@ -176,7 +215,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
ssn.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ssn.send("250 I have sucessfully done nothing")
ssn.send("250 I have successfully done nothing")
continue
case "RSET":
// Reset session
@@ -219,7 +258,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
// Not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
@@ -257,10 +296,11 @@ func (s *Session) greetHandler(cmd string, arg string) {
return
}
s.remoteDomain = domain
// features before SIZE per RFC
// Features before SIZE per RFC
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
s.send("250-AUTH PLAIN LOGIN")
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))
@@ -276,79 +316,166 @@ 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() {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler() {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
}
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
// invalid command since unconfigured
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")
return
}
if s.tlsState != nil {
// tls state previously valid
// TLS state previously valid.
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
s.send("454 A TLS session already agreed upon.")
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("220 STARTTLS")
// start tls connection handshake
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
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 == "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>")
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")
return
case "LOGIN":
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
default:
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
case "MAIL":
s.parseMailFromCmd(arg)
case "EHLO":
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
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
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
// 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)
// 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 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
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else {
s.ooSeq(cmd)
}
// 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
@@ -367,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
@@ -394,6 +540,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
}
s.enterState(DATA)
return
case "EHLO":
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
return
}
s.ooSeq(cmd)
}
@@ -414,30 +566,24 @@ func (s *Session) dataHandler() {
}
mailData := bytes.NewBuffer(msgBuf)
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
// Generate Received header; Deliver() will append recipient and timestamp to this.
recvdHeader := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain)
// Deliver message.
if err := s.manager.Deliver(s.from, s.recipients, recvdHeader, mailData.Bytes()); err != nil {
// Deliver() logs failure details, and the effected mailbox.
s.send("451 Failed to store message")
s.reset()
return
}
// TODO Consider changing this to just 1 regardless of # of recipents.
expReceivedTotal.Add(int64(len(s.recipients)))
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
s.reset()
return
}
func (s *Session) enterState(state State) {
@@ -502,38 +648,41 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
s.logger.Debug().Msgf("Line received: %v", line)
// Find length of command or entire line.
hasArg := true
l := strings.IndexByte(line, ' ')
if l == -1 {
hasArg = false
l = len(line)
}
switch {
case l == 0:
return "", "", true
case l < 4:
s.logger.Warn().Msgf("Command too short: %q", line)
return "", "", false
case l == 4 || l == 8:
return strings.ToUpper(line), "", true
case l == 5:
// Too long to be only command, too short to have args
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
if hasArg {
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
return strings.ToUpper(line), "", true
}
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
// " BODY=8BITMIME SIZE=1024"
//
// " BODY=8BITMIME SIZE=1024"
//
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
@@ -548,7 +697,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
func (s *Session) reset() {
s.enterState(READY)
s.from = ""
s.from = nil
s.recipients = nil
}
@@ -556,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

@@ -1,22 +1,24 @@
package smtp
import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type scriptStep struct {
@@ -24,14 +26,40 @@ type scriptStep struct {
expect int
}
// Test commands in GREET state
// Test valid commands in GREET state.
func TestGreetStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"HELO mydomain", 250},
{"HELO mydom.com", 250},
{"HelO mydom.com", 250},
{"helo 127.0.0.1", 250},
{"HELO ABC", 250},
{"EHLO mydomain", 250},
{"EHLO mydom.com", 250},
{"EhlO mydom.com", 250},
{"ehlo 127.0.0.1", 250},
{"EHLO a", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid commands in GREET state.
func TestGreetState(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some mangled HELOs
script := []scriptStep{
tests := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
@@ -39,55 +67,149 @@ func TestGreetState(t *testing.T) {
{"hello", 500},
{"Outlook", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Valid HELOs
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test commands in READY state
func TestReadyState(t *testing.T) {
// 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, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some mangled READY commands
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 250},
}
playSession(t, server, script)
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 250},
}
playSession(t, server, script)
}
// Test AUTH commands.
func TestAuth(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// PLAIN AUTH
script := []scriptStep{
{"EHLO localhost", 250},
{"AUTH PLAIN aW5idWNrZXQ6cGFzc3dvcmQK", 235},
{"RSET", 250},
{"AUTH GSSAPI aW5idWNrZXQ6cGFzc3dvcmQK", 500},
{"RSET", 250},
{"AUTH PLAIN", 500},
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
playSession(t, server, script)
// LOGIN AUTH
script = []scriptStep{
{"EHLO localhost", 250},
{"AUTH LOGIN", 334}, // Test with user/pass present.
{"username", 334},
{"password", 235},
{"RSET", 250},
{"AUTH LOGIN", 334}, // Test with empty user/pass.
{"", 334},
{"", 235},
}
playSession(t, server, script)
}
// Test TLS commands.
func TestTLS(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// Test Start TLS parsing.
script := []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
playSession(t, server, script)
}
// Test valid commands in READY state.
func TestReadyStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some valid MAIL commands
tests := []scriptStep{
{"MAIL FROM:<john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid domains in READY state.
func TestReadyStateRejectedDomains(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"MAIL FROM: <john@validdomain.com>", 250},
{"MAIL FROM: <john@invalidomain.com>", 501},
{"MAIL FROM: <john@s1.otherinvaliddomain.com>", 501},
{"MAIL FROM: <john@s2.otherinvaliddomain.com>", 501},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid commands in READY state.
func TestReadyStateInvalidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"FOOB", 500},
{"HELO", 503},
{"DATA", 503},
@@ -99,50 +221,22 @@ func TestReadyState(t *testing.T) {
{"MAIL FROM:<first@last@gmail.com>", 501},
{"MAIL FROM:<first last@gmail.com>", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out some valid MAIL commands
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test commands in MAIL state
func TestMailState(t *testing.T) {
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
server := setupSMTPServer(mds, extension.NewHost())
// Test out some mangled READY commands
script := []scriptStep{
@@ -158,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{
@@ -174,10 +266,10 @@ func TestMailState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{`RCPT TO:<"first/last"@host.com`, 250},
{"RCPT TO:<u1@[127.0.0.1]>", 250},
{"RCPT TO:<u1@[IPv6:2001:db8:aaaa:1::100]>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test out recipient limit
script = []scriptStep{
@@ -190,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{
@@ -202,9 +292,18 @@ 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{
{"EHLO localhost", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
playSession(t, server, script)
// Test RSET
script = []scriptStep{
@@ -214,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{
@@ -225,26 +322,16 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"QUIT", 221},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
playSession(t, server, script)
}
// Test commands in DATA state
func TestDataState(t *testing.T) {
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
server := setupSMTPServer(mds, extension.NewHost())
var script []scriptStep
pipe := setupSMTPSession(server)
pipe := setupSMTPSession(t, server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
@@ -256,9 +343,8 @@ func TestDataState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
playScriptAgainst(t, c, script)
// Send a message
body := `To: u1@gmail.com
From: john@gmail.com
@@ -272,9 +358,11 @@ Hi!
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
// Test with no useful headers.
pipe = setupSMTPSession(server)
pipe = setupSMTPSession(t, server)
c = textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
@@ -285,54 +373,299 @@ Hi!
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
playScriptAgainst(t, c, script)
// Send a message
body = `X-Useless-Header: true
Hi! Can you still deliver this?
`
Hi! Can you still deliver this?
`
dw = c.DotWriter()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
// Tests "MAIL FROM" emits 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 {
pipe := setupSMTPSession(server)
func playSession(t *testing.T, server *Server, script []scriptStep) {
t.Helper()
pipe := setupSMTPSession(t, server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
return fmt.Errorf("Expected a 220 greeting, got %v", code)
t.Errorf("expected a 220 greeting, got %v", code)
}
err := playScriptAgainst(t, c, script)
playScriptAgainst(t, c, script)
// Not all tests leave the session in a clean state, so the following two
// calls can fail
// Not all tests leave the session in a clean state, so the following two calls can fail
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
return err
}
// playScriptAgainst an existing connection, does not handle server greeting
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) error {
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) {
t.Helper()
for i, step := range script {
id, err := c.Cmd(step.send)
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)
@@ -344,11 +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
}
// net.Pipe does not implement deadlines
@@ -360,41 +692,46 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
// Creates an unstarted smtp.Server.
func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
cfg := &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
Timeout: 5,
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
RejectOriginDomains: []string{"invalidomain.com", "*.otherinvaliddomain.com"},
Timeout: 5,
},
}
// Capture log output.
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it.
shutdownChan := make(chan bool)
teardown = func() {
close(shutdownChan)
}
// Create a server, but don't start it.
addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds}
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
return s, buf, teardown
manager := &message.StoreManager{Store: ds, ExtHost: extHost}
return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
}
var sessionNum int
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate.
func setupSMTPSession(t *testing.T, server *Server) net.Conn {
t.Helper()
logger := zerolog.New(zerolog.NewTestWriter(t))
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
_ = clientConn.Close()
// Drain is required to prevent a test-logging data race. If a (failing) test run is
// hanging, this may be the culprit.
server.Drain()
})
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
go server.startSession(sessionNum, &mockConn{serverConn}, logger)
return clientConn
}

View File

@@ -9,10 +9,11 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/metric"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/rs/zerolog/log"
)
@@ -58,21 +59,22 @@ func init() {
// Server holds the configuration and state of our SMTP server.
type Server struct {
config config.SMTP // SMTP configuration.
addrPolicy *policy.Addressing // Address policy.
globalShutdown chan bool // Shuts down Inbucket.
manager message.Manager // Used to deliver messages.
listener net.Listener // Incoming network connections.
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
tlsConfig *tls.Config
config config.SMTP // SMTP configuration.
tlsConfig *tls.Config // TLS encryption configuration.
addrPolicy *policy.Addressing // Address policy.
manager message.Manager // Used to deliver messages.
extHost *extension.Host // Extension event processor.
listener net.Listener // Incoming network connections.
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
notify chan error // Notify on fatal error.
}
// NewServer creates a new Server instance with the specificed config.
// NewServer creates a new, unstarted, SMTP server instance with the specificed config.
func NewServer(
smtpConfig config.SMTP,
globalShutdown chan bool,
manager message.Manager,
apolicy *policy.Addressing,
extHost *extension.Host,
) *Server {
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{}
@@ -90,37 +92,48 @@ func NewServer(
}
return &Server{
config: smtpConfig,
globalShutdown: globalShutdown,
manager: manager,
addrPolicy: apolicy,
wg: new(sync.WaitGroup),
tlsConfig: tlsConfig,
config: smtpConfig,
tlsConfig: tlsConfig,
manager: manager,
addrPolicy: apolicy,
extHost: extHost,
wg: new(sync.WaitGroup),
notify: make(chan error, 1),
}
}
// Start the listener and handle incoming connections.
func (s *Server) Start(ctx context.Context) {
func (s *Server) Start(ctx context.Context, readyFunc func()) {
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr)
if s.config.ForceTLS {
s.listener, err = tls.Listen("tcp4", addr.String(), s.tlsConfig)
} else {
s.listener, err = net.ListenTCP("tcp4", addr)
}
if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
// Listener go routine.
// Start listener go routine.
go s.serve(ctx)
readyFunc()
// Wait for shutdown.
<-ctx.Done()
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit.
if err := s.listener.Close(); err != nil {
slog.Error().Err(err).Msg("Failed to close SMTP listener")
@@ -134,18 +147,18 @@ func (s *Server) serve(ctx context.Context) {
for sessionID := 1; ; sessionID++ {
if conn, err := s.listener.Accept(); err != nil {
// There was an error accepting the connection.
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again.
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
// Timeout, sleep for a bit and try again.
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
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 error; retrying in %v", tempDelay)
Msgf("SMTP accept timeout; retrying in %v", tempDelay)
time.Sleep(tempDelay)
continue
} else {
@@ -156,31 +169,25 @@ func (s *Server) serve(ctx context.Context) {
return
default:
// Something went wrong.
s.emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
}
} else {
tempDelay = 0
expConnectsTotal.Add(1)
s.wg.Add(1)
go s.startSession(sessionID, conn)
go s.startSession(sessionID, conn, log.Logger)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket.
select {
case <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close.
s.wg.Wait()
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained")
}
// Notify allows the running SMTP server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
}

View File

@@ -0,0 +1,6 @@
package web
type jsonAppConfig struct {
BasePath string `json:"base-path"`
MonitorVisible bool `json:"monitor-visible"`
}

View File

@@ -5,17 +5,15 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
)
// Context is passed into every request handler function
// TODO remove redundant web config
type Context struct {
Vars map[string]string
Session *sessions.Session
MsgHub *msghub.Hub
Manager message.Manager
RootConfig *config.Root
@@ -48,24 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
// NewContext returns a Context for the given HTTP Request
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
if err != nil {
if sess == nil {
// No session, must fail
return nil, err
}
// The session cookie was probably signed by an old key, ignore it
// gorilla created an empty session for us
err = nil
}
ctx := &Context{
Vars: vars,
Session: sess,
MsgHub: msgHub,
Manager: manager,
RootConfig: rootConfig,
WebConfig: rootConfig.Web,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
return ctx, nil
}

105
pkg/server/web/handlers.go Normal file
View File

@@ -0,0 +1,105 @@
package web
import (
"html/template"
"net/http"
"os"
"github.com/rs/zerolog/log"
)
// Handler is a function type that handles an HTTP request in Inbucket.
type Handler func(http.ResponseWriter, *http.Request, *Context) error
// ServeHTTP builds the context and passes onto the real handler.
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context.
ctx, err := NewContext(req)
if err != nil {
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it.
err = h(w, req, ctx)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// cookieHandler injects an HTTP cookie into the response.
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
http.SetCookie(w, cookie)
next.ServeHTTP(w, req)
})
}
// fileHandler creates a handler that sends the named file regardless of the requested URL.
func fileHandler(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
f, err := os.Open(name)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
Msg("Error opening file")
http.Error(w, "Error opening file", http.StatusInternalServerError)
return
}
defer f.Close()
d, err := f.Stat()
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
Msg("Error stating file")
http.Error(w, "Error opening file", http.StatusInternalServerError)
return
}
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
})
}
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
// returning specified statusCode to the client.
func noMatchHandler(statusCode int, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
w.WriteHeader(statusCode)
})
}
// requestLoggingWrapper returns middleware that logs client requests.
func requestLoggingWrapper(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
next.ServeHTTP(w, req)
})
}
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
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")
err := tmpl.Execute(w, tmplData)
if err != nil {
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
Msg("Error rendering SPA index template")
}
})
}

View File

@@ -3,66 +3,24 @@ package web
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"address": stringutil.StringAddress,
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"stringsJoin": strings.Join,
"textToHtml": TextToHTML,
}
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
// otherwise Mon Jan 2, 2006
func FriendlyTime(t time.Time) template.HTML {
ty, tm, td := t.Date()
ny, nm, nd := time.Now().Date()
if (ty == ny) && (tm == nm) && (td == nd) {
return template.HTML(t.Format("03:04:05 PM"))
}
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// Reverse routing function (shared with templates)
func Reverse(name string, things ...interface{}) string {
// Convert the things to strings
strs := make([]string, len(things))
for i, th := range things {
strs[i] = fmt.Sprint(th)
}
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.Error().Str("module", "web").Str("name", name).Err(err).
Msg("Failed to reverse route")
return "/ROUTE-ERROR"
}
return u.Path
}
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func TextToHTML(text string) template.HTML {
func TextToHTML(text string) string {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text))
return replacer.Replace(text)
}
// 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

@@ -1,30 +1,55 @@
package web
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;"))
// Check for linebreaks
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
TextToHTML("http://google.com/"),
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
assert.Equal(t,
TextToHTML("http://a.com/?q=a&n=v"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
testCases := []struct {
input, want string
}{
{
input: "html",
want: "html",
},
// Check it escapes.
{
input: "<html>",
want: "&lt;html&gt;",
},
// Check for linebreaks.
{
input: "line\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\r\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\rbreak",
want: "line<br/>\nbreak",
},
// Check URL detection.
{
input: "http://google.com/",
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
},
{
input: "http://a.com/?q=a&n=v",
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>",
},
{
input: "(http://a.com/?q=a&n=v)",
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>)",
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got := TextToHTML(tc.input)
if got != tc.want {
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
}
})
}
}

View File

@@ -3,30 +3,25 @@ package web
import (
"context"
"encoding/json"
"expvar"
"html/template"
"net"
"net/http"
"net/http/pprof"
"net/url"
"os"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// Handler is a function type that handles an HTTP request in Inbucket
type Handler func(http.ResponseWriter, *http.Request, *Context) error
const (
staticDir = "static"
templateDir = "templates"
)
var (
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
@@ -36,11 +31,9 @@ var (
// incoming requests to the correct handler function
Router = mux.NewRouter()
rootConfig *config.Root
server *http.Server
listener net.Listener
sessionStore sessions.Store
globalShutdown chan bool
rootConfig *config.Root
server *http.Server
listener net.Listener
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
@@ -51,54 +44,100 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method
func Initialize(
conf *config.Root,
shutdownChan chan bool,
mm message.Manager,
mh *msghub.Hub) {
// Server defines an instance of the Web server.
type Server struct {
// TODO Migrate global vars here.
notify chan error // Notify on fatal error.
}
// NewServer sets up things for unit tests or the Start() method.
func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
// NewContext() will use this DataStore for the web handlers.
msgHub = mh
manager = mm
// Content Paths
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(staticPath))))
Router.Handle("/debug/vars", expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to /debug/pprof")
// Redirect requests to / if there is a base path configured.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
redirectBase := prefix("/")
if redirectBase != "/" {
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
Msg("Base path configured")
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
// Redirect prefix when missing trailing slash.
Router.Path(prefix("")).Handler(http.RedirectHandler(redirectBase, http.StatusFound))
}
// Session cookie setup
if conf.Web.CookieAuthKey == "" {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key")
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
} else {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
// Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
}
// Static paths.
Router.PathPrefix(prefix("/static")).Handler(
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path(prefix("/favicon.png")).Handler(
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
// Parse index.html template, allowing for configuration to be passed to the SPA.
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
indexTmpl, err := template.ParseFiles(indexPath)
if err != nil {
msg := "Failed to parse HTML template"
cwd, _ := os.Getwd()
log.Error().
Str("module", "web").
Str("phase", "startup").
Str("path", indexPath).
Str("cwd", cwd).
Err(err).
Msg(msg)
// Create a dummy template to allow tests to pass.
indexTmpl, _ = template.New("index.html").Parse(msg)
}
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
spaTemplateHandler(indexTmpl, prefix("/")))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
// Error handlers.
Router.NotFoundHandler = noMatchHandler(
http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path")
s := &Server{
notify: make(chan error, 1),
}
return s
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
func (s *Server) Start(ctx context.Context, readyFunc func()) {
var (
err error
listenCfg net.ListenConfig
)
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: Router,
Handler: requestLoggingWrapper(Router),
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
@@ -106,24 +145,27 @@ func Start(ctx context.Context) {
// 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")
emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
// Listener go routine
go serve(ctx)
// Start listener go routine
go s.serve(ctx)
readyFunc()
// Wait for shutdown
select {
case _ = <-ctx.Done():
log.Debug().Str("module", "web").Str("phase", "shutdown").
Msg("HTTP server shutting down on request")
}
<-ctx.Done()
log.Debug().Str("module", "web").Str("phase", "shutdown").
Msg("HTTP server shutting down on request")
// Closing the listener will cause the serve() go routine to exit
if err := listener.Close(); err != nil {
@@ -132,50 +174,41 @@ func Start(ctx context.Context) {
}
}
func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{
BasePath: webConfig.BasePath,
MonitorVisible: webConfig.MonitorVisible,
}
b, err := json.Marshal(o)
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("Failed to convert app-config to JSON")
}
return &http.Cookie{
Name: "app-config",
Value: url.PathEscape(string(b)),
Path: "/",
}
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
func (s *Server) serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case _ = <-ctx.Done():
case <-ctx.Done():
// Nop
default:
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP server failed")
emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
}
// ServeHTTP builds the context and passes onto the real handler
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context
ctx, err := NewContext(req)
if err != nil {
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
err = h(w, req, ctx)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
// Notify allows the running Web server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
}

View File

@@ -1,84 +0,0 @@
package web
import (
"html/template"
"net/http"
"path"
"path/filepath"
"sync"
"github.com/rs/zerolog/log"
)
var cachedMutex sync.Mutex
var cachedTemplates = map[string]*template.Template{}
var cachedPartials = map[string]*template.Template{}
// RenderTemplate fetches the named template and renders it to the provided
// ResponseWriter.
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, false)
if err != nil {
log.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// RenderPartial fetches the named template and renders it to the provided
// ResponseWriter.
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, true)
if err != nil {
log.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// ParseTemplate loads the requested template along with _base.html, caching
// the result (if configured to do so)
func ParseTemplate(name string, partial bool) (*template.Template, error) {
cachedMutex.Lock()
defer cachedMutex.Unlock()
if t, ok := cachedTemplates[name]; ok {
return t, nil
}
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
var err error
var t *template.Template
if partial {
// Need to get basename of file to make it root template w/ funcs
base := path.Base(name)
t = template.New(base).Funcs(TemplateFuncs)
t, err = t.ParseFiles(tempFile)
} else {
t = template.New("_base.html").Funcs(TemplateFuncs)
t, err = t.ParseFiles(
filepath.Join(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if rootConfig.Web.TemplateCache {
if partial {
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
cachedTemplates[name] = t
} else {
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
cachedTemplates[name] = t
}
}
return t, nil
}

View File

@@ -2,16 +2,20 @@ package file
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -21,7 +25,7 @@ const indexFileName = "index.gob"
var (
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
// because we only want one regardless of the number of DataStore objects
// because we only want one regardless of the number of DataStore objects.
countChannel = make(chan int, 10)
)
@@ -30,7 +34,7 @@ func init() {
go countGenerator(countChannel)
}
// Populates the channel with numbers
// Populates the channel with numbers.
func countGenerator(c chan int) {
for i := 0; true; i = (i + 1) % 10000 {
c <- i
@@ -38,29 +42,33 @@ func countGenerator(c chan int) {
}
// Store implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
// hiearchy. It provides access to Mailbox objects.
type Store struct {
hashLock storage.HashLock
path string
mailPath string
messageCap int
bufReaderPool sync.Pool
extHost *extension.Host
}
// New creates a new DataStore object using the specified path
func New(cfg config.Storage) (storage.Store, error) {
// New creates a new DataStore object using the specified path.
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
path := cfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("'path' parameter not specified")
return nil, errors.New("'path' parameter not specified")
}
mailPath := filepath.Join(path, "mail")
mailPath := getMailPath(path)
if _, err := os.Stat(mailPath); err != nil {
// Mail datastore does not yet exist
// Mail datastore does not yet exist, create it.
if err = os.MkdirAll(mailPath, 0770); err != nil {
log.Error().Str("module", "storage").Str("path", mailPath).Err(err).
Msg("Error creating dir")
return nil, err
}
}
return &Store{
path: path,
mailPath: mailPath,
@@ -70,6 +78,7 @@ func New(cfg config.Storage) (storage.Store, error) {
return bufio.NewReader(nil)
},
},
extHost: extHost,
}, nil
}
@@ -82,16 +91,19 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
if err != nil {
return "", err
}
// Create a new message.
fm, err := mb.newMessage()
if err != nil {
return "", err
}
// Ensure mailbox directory exists.
if err := mb.createDir(); err != nil {
return "", err
}
// Write the message content
// Write the message content.
file, err := os.Create(fm.rawPath())
if err != nil {
return "", err
@@ -99,23 +111,24 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
w := bufio.NewWriter(file)
size, err := io.Copy(w, r)
if err != nil {
// Try to remove the file
// Try to remove the file.
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
_ = r.Close()
if err := w.Flush(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
if err := file.Close(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = os.Remove(fm.rawPath())
return "", err
}
// Update the index.
fm.Fdate = m.Date()
fm.Ffrom = m.From()
@@ -124,10 +137,11 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
fm.Fsubject = m.Subject()
mb.messages = append(mb.messages, fm)
if err := mb.writeIndex(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = os.Remove(fm.rawPath())
return "", err
}
return fm.Fid, nil
}
@@ -152,11 +166,13 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
if m.Fid == id {
if m.Fseen {
@@ -167,6 +183,7 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
break
}
}
return mb.writeIndex()
}
@@ -183,6 +200,17 @@ func (fs *Store) PurgeMessages(mailbox string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
// Emit delete events.
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
fs.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return mb.purge()
}
@@ -193,19 +221,22 @@ func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
if err != nil {
return err
}
// Loop over level 1 directories
// Loop over level 1 directories.
for _, name1 := range names1 {
names2, err := readDirNames(fs.mailPath, name1)
if err != nil {
return err
}
// Loop over level 2 directories
// Loop over level 2 directories.
for _, name2 := range names2 {
names3, err := readDirNames(fs.mailPath, name1, name2)
if err != nil {
return err
}
// Loop over mailboxes
// Loop over mailboxes.
for _, name3 := range names3 {
mb := fs.mboxFromHash(name3)
mb.RLock()
@@ -230,6 +261,7 @@ func (fs *Store) mbox(mailbox string) *mbox {
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
@@ -246,6 +278,7 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
@@ -280,11 +313,23 @@ func generateID(date time.Time) string {
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
}
// getMailPath converts a filestore `path` parameter into the effective mail store path.
// Within the path, '$' is replaced with ':' to support Windows drive letters with our
// env->config map syntax.
func getMailPath(base string) string {
path := strings.ReplaceAll(base, "$", ":")
return filepath.Join(path, "mail")
}
// readDirNames returns a slice of filenames in the specified directory or an error.
func readDirNames(elem ...string) ([]string, error) {
f, err := os.Open(filepath.Join(elem...))
if err != nil {
return nil, err
}
defer func() {
_ = f.Close()
}()
return f.Readdirnames(0)
}

View File

@@ -2,38 +2,54 @@ package file
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"net/mail"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
ds, _ := setupDataStore(conf)
destroy := func() {
teardownDataStore(ds)
}
return ds, destroy, nil
})
test.StoreSuite(t,
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
ds, _ := setupDataStore(conf, extHost)
destroy := func() {
teardownDataStore(ds)
}
return ds, destroy, nil
})
}
// Test filestore initialization.
func TestFSNew(t *testing.T) {
// Should fail if no path specified.
ds, err := New(config.Storage{}, extension.NewHost())
require.ErrorContains(t, err, "parameter not specified")
assert.Nil(t, ds)
}
func TestFSGetMailPath(t *testing.T) {
// Path should have `mail` dir appended.
got := getMailPath(`one`)
assert.Regexp(t, "^one.mail$", got, "Expected one/mail or similar")
// Path should convert `$` to `:`.
got = getMailPath(`C$\inbucket`)
assert.Regexp(t, "^C:.inbucket.mail$", got, "Expected C:\\inbucket\\mail or similar")
}
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
root := ds.path
@@ -52,7 +68,7 @@ func TestFSDirStructure(t *testing.T) {
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
id1, _ := test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
@@ -69,7 +85,7 @@ func TestFSDirStructure(t *testing.T) {
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
@@ -79,7 +95,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err := ds.RemoveMessage(mbName, id1)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
@@ -89,7 +105,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err = ds.RemoveMessage(mbName, id2)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
@@ -111,7 +127,7 @@ func TestFSDirStructure(t *testing.T) {
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
mbName := "fred"
@@ -120,21 +136,21 @@ func TestFSMissing(t *testing.T) {
for i, subj := range subjects {
// Add a message
id, _ := deliverMessage(ds, mbName, subj, time.Now())
id, _ := test.DeliverToStore(t, ds, mbName, subj, time.Now())
sentIds[i] = id
}
// Delete a message file without removing it from index
msg, err := ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
fmsg := msg.(*Message)
_ = os.Remove(fmsg.rawPath())
msg, err = ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
// Try to read parts of message
_, err = msg.Source()
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -146,7 +162,7 @@ func TestFSMissing(t *testing.T) {
// Test Get the latest message
func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
@@ -155,29 +171,29 @@ func TestGetLatestMessage(t *testing.T) {
// Test empty mailbox
msg, err := ds.GetMessage(mbName, "latest")
assert.Nil(t, msg)
assert.Error(t, err)
require.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Test get the latest message
msg, err = ds.GetMessage(mbName, "latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
require.NoError(t, err)
assert.Equal(t, id2, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
id3, _ := test.DeliverToStore(t, ds, mbName, "test 3", time.Now())
msg, err = ds.GetMessage(mbName, "latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
require.NoError(t, err)
assert.Equal(t, id3, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = ds.GetMessage(mbName, "wrongid")
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -188,47 +204,26 @@ func TestGetLatestMessage(t *testing.T) {
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes.Buffer) {
path, err := os.MkdirTemp("", "inbucket")
if err != nil {
panic(err)
}
// Capture log output.
buf := new(bytes.Buffer)
log.SetOutput(buf)
if cfg.Params == nil {
cfg.Params = make(map[string]string)
}
cfg.Params["path"] = path
s, err := New(cfg)
s, err := New(cfg, extHost)
if err != nil {
panic(err)
}
return s.(*Store), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
// Build message for delivery
meta := message.Metadata{
Mailbox: mbName,
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
}
id, err := ds.AddMessage(delivery)
if err != nil {
panic(err)
}
return id, int64(len(testMsg))
return s.(*Store), buf
}
func teardownDataStore(ds *Store) {

View File

@@ -9,7 +9,8 @@ import (
"path/filepath"
"sync"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog/log"
)
@@ -72,6 +73,10 @@ func (mb *mbox) removeMessage(id string) error {
msg = m
// Slice around message we are deleting
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
// Emit deleted event.
mb.store.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(msg))
break
}
}
@@ -107,6 +112,8 @@ func (mb *mbox) readIndex() error {
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
Msg("Index does not yet exist")
mb.indexLoaded = true
//lint:ignore nilerr missing mailboxes are considered empty.
return nil
}
file, err := os.Open(mb.indexPath)
@@ -125,7 +132,7 @@ func (mb *mbox) readIndex() error {
dec := gob.NewDecoder(br)
name := ""
if err = dec.Decode(&name); err != nil {
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
mb.name = name
for {
@@ -135,7 +142,7 @@ func (mb *mbox) readIndex() error {
if err == io.EOF {
break
}
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
msg.mailbox = mb
mb.messages = append(mb.messages, msg)

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

@@ -3,7 +3,7 @@ package storage_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
func TestHashLock(t *testing.T) {
@@ -36,7 +36,7 @@ func TestHashLock(t *testing.T) {
t.Run(ts, func(t *testing.T) {
l := hl.Get(ts)
if l == nil {
t.Errorf("Expeced non-nil lock for hex string %q", ts)
t.Errorf("Expected non-nil lock for hex string %q", ts)
}
})
}

View File

@@ -4,11 +4,10 @@ import (
"bytes"
"container/list"
"io"
"io/ioutil"
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
// Message is a memory store message.
@@ -47,7 +46,7 @@ func (m *Message) Subject() string { return m.subject }
// Source returns a reader for the message source.
func (m *Message) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
return io.NopCloser(bytes.NewReader(m.source)), nil
}
// Size returns the message size in bytes.

Some files were not shown because too many files have changed in this diff Show More