1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 05:32:31 +00:00

Compare commits

...

259 Commits

Author SHA1 Message Date
Hillyerd Bot 742249691d chore: release 3.2.0 (#632)
* chore: release 3.2.0

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

* docs: add missing PR #598 to GitHub Actions changelog entry

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
Co-authored-by: James Hillyerd <james@hillyerd.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-10 13:36:06 -07:00
Hillyerd Bot 6d3d2a99c0 deps: bump indirect dependencies to latest compatible releases (#631)
- github.com/goccy/go-json v0.10.6 -> v0.11.2
- github.com/mattn/go-colorable v0.1.15 -> v0.1.16
- github.com/mattn/go-runewidth v0.0.29 -> v0.0.31
- github.com/olekukonko/ll v0.1.8 -> v0.1.9
- github.com/olekukonko/tablewriter v1.1.4 -> v1.1.5

golang.org/x/net, x/sys and x/text were left at their current versions
because their newest releases require Go 1.26, while this module targets
go 1.25.

Build and go test -race ./... pass.
2026-10-10 12:23:21 -07:00
James Hillyerd 26df522a5a test(policy): cover origin parsing and accept/store policy dispatch (#624) 2026-10-05 20:05:17 -07:00
Antoine Deschênes 8075e8d2a3 feat: reload TLS certificates when their files change (#625) 2026-10-05 14:49:35 -07:00
Antoine Deschênes 9876bb2bd9 fix(pop3): track STLS state per session instead of per server (#626)
The TLS connection state lived on the shared POP3 Server, and Session
reached it through its embedded *Server.  Once any client completed
STLS, every later session saw a non-nil state: CAPA stopped advertising
STLS and the STLS command returned "-ERR A TLS session already agreed
upon." until the server restarted.  Sessions also wrote the shared field
concurrently without synchronization.

Move tlsState to Session.  ForceTLS sessions set it when the session
starts, and STLS sets it on the negotiating session only.

TestSTLSPerSession runs CAPA, STLS and a TLS handshake on two sessions of
one server; it fails against the previous code on the second session.

Signed-off-by: Antoine Deschênes <antoine@antoinedeschenes.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:32 -07:00
James Hillyerd 94472ab496 fix(rest): make WebSocket monitor listener teardown safe under concurrency (#623)
* fix(rest): make WebSocket monitor listener teardown safe under concurrency

The commit-1 tests and race detector exposed three related defects in the
monitor listener's Close, whose check-then-act select was unsound:

1. With a full event queue (a slow or disconnecting client), the
   `case <-ml.c:` branch received a buffered event, mistook it for the
   closed-channel signal, and skipped both RemoveListener and close: the
   listener stayed registered, and the hub actor then blocked forever
   inside Receive on the next dispatch, wedging every hub consumer.
2. The read and write pumps both call Close when the connection drops;
   two callers could pass the select's default branch together and the
   second close panicked with "close of closed channel".
3. Even made exactly-once, closing the event queue was still unsafe:
   RemoveListener returns once its op is queued, not processed, so the
   hub actor could be mid-send into the queue when Close closed it
   (send on closed channel panic inside the actor).

Fix: Close deregisters and closes a `done` channel via sync.Once; the
event queue is never closed.  Receive and Delete select their enqueue
against done and return errListenerClosed when aborted, so the hub
removes the listener itself even if RemoveListener has not run yet, and
a blocked enqueue aborts instead of wedging the actor.  The write pump
exits on done and sends the closing frame.

Each defect has a regression test that fails against the previous code
(verified: no-op close delivered post-close events, concurrent closes
panicked, and close raced the actor's blocked send).  The write pump
keeps its c-close branch for safety; the black-box monitor tests from
commit 1 pass unchanged.

* fix(rest): close listener done channel before enqueueing removal

Copilot review on PR #623 correctly identified a remaining deadlock in
msgListener.Close: when the hub actor is blocked inside this listener's
Receive (full event queue) and the hub op queue is also full,
RemoveListener cannot enqueue, so execution never reached close(ml.done) --
the only operation able to release the actor.  Under the same slow-client
condition, concurrent dispatchers keeping the hub queue saturated made
Close hang permanently.

Fix: close the done channel (guarded by the existing sync.Once) before
queueing the RemoveListener op.  The blocked actor is released first, keeps
draining the op queue, and the removal can then be queued even when the
queue is full.  As a side effect, a Close racing a not-yet-processed
AddListener now also deregisters correctly, since the removal op can no
longer be overtaken by the earlier registration op.

Regression test TestMsgListenerCloseFullHubOpQueue wedges the actor in
Receive with a saturated op queue before calling Close; verified it fails
("Close deadlocked") against the previous ordering and passes with -race
after the fix, along with the full pkg/rest and pkg/msghub suites.
2026-09-20 11:57:04 -07:00
James Hillyerd 47239c08eb refactor(rest): deduplicate WebSocket monitor controllers (#622)
The black-box monitor tests showed socketv1_controller.go and
socketv2_controller.go were near-identical: same constants (identical
values), upgrader, read/write pumps, Close, and handler bodies, differing
only in the event payload shape and v1 dropping deletions.

Extract the shared machinery into socket_controller.go:

- msgListener[T] generic listener with toStored/toDeleted payload
  transforms; a nil toDeleted preserves the socketv1 behavior of ignoring
  deletions
- socketReadPump / socketWritePump free functions (WriteJSON is
  payload-type agnostic)
- serveMonitor shared handler body (upgrade, gauge tracking, listener
  registration, pump wiring)

The version files shrink to their listener constructors and the four
exported handlers, whose signatures are unchanged.  v1 payloads are now
shaped at enqueue time rather than write time; the JSON on the wire is
identical, pinned by the commit-1 tests, which pass unmodified.

Adds direct unit tests for the extracted listener logic: watches()
filtering, Receive/Delete filtering + payload shaping, nil-toDeleted
deletion dropping, and sequential Close idempotency.
2026-09-17 18:43:34 -07:00
James Hillyerd 9aaad6b950 test(rest): add black-box WebSocket monitor tests (#621)
Cover the REST WebSocket monitor endpoints (v1 + v2), previously at 0%
for all 18 functions in socketv1_controller.go and socketv2_controller.go;
pkg/rest coverage goes from 20.9% to 73.2%.

The harness dials a real gorilla/websocket client into an httptest server
wired to a running msghub.Hub, and relies on the hub's history replay for
deterministic ordering: dispatching before dialing seeds the replay, and
events are relayed in dispatch order, so receiving event N next proves
filtered events were skipped rather than delayed.

Covered for both API versions:

- monitor-all and per-mailbox endpoints, including history replay and live
  delivery, and mailbox filtering of both message-stored and (v2)
  message-deleted events
- address-form mailbox names via Manager.MailboxForAddress (user%40example.com)
- invalid mailbox names rejected with 500 before the upgrade, and plain GET
  requests failing the handshake with 400
- client data messages discarded by the read pump
- clean client shutdown: listener deregistration, empty close frame from
  the writer racing the read pump's close-code echo, and the
  WebSocketConnectsCurrent expvar gauge increment/decrement

Tests only; no production changes.  Notes for follow-up commits:

- v1 monitors ignore deletions while v2 reports them (pinned as current
  behavior in TestMonitorMailboxMessagesV1)
- httptest.Server.Close does not wait for hijacked connections, so
  dialMonitor's cleanup awaits the handler goroutine via the connections
  gauge before the hub is stopped; the read/write pumps calling
  msgListener.Close concurrently can still double-close its channel
  (check-then-act race in Close's select) and deserves a fix
- deadline-set error paths, the 54s ping/pong cycle, and write-failure
  paths remain uncovered, consistent with the POP3 send-error paths
2026-09-17 18:23:26 -07:00
James Hillyerd a85a4ab996 fix(pop3): reject deleted messages and failed TLS handshakes (#620)
- RETR and TOP now respond "-ERR You deleted message N" for messages
  marked deleted, as RFC 1939 sections 5 (RETR) and 7 (TOP) require;
  previously the message content was still sent.
- STLS: a failed TLS handshake previously continued the session with
  the dead TLS connection installed as s.conn and a bogus tlsState,
  producing confusing follow-on errors. RFC 2595 section 4 requires
  the negotiation to complete before further commands, so a failure
  is now fatal: log it and terminate the session.

Updates the scripted tests that previously pinned the old behavior,
and adds a STLS handshake failure regression test.

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-16 18:10:40 -07:00
James Hillyerd 8bad098be9 refactor(pop3): extract transaction handler helpers (#619)
- validateMsgNum parses and range-checks message-number arguments,
  replacing five copies of the parse/check/respond sequence in the
  LIST, UIDL, DELE, RETR and TOP arms (wire responses unchanged).
- listMessages implements LIST and UIDL, which differ only in the
  per-message value reported.
- sendMessageLines unifies sendMessage/sendMessageTop via a body line
  limit, negative meaning unlimited.

Adds a direct unit test for validateMsgNum and an equivalence test
asserting TOP with a large line limit matches RETR output. All
scripted session tests from the previous commit pass unchanged.

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-16 18:03:59 -07:00
James Hillyerd 68fc60fe9f test(pop3): add black-box transaction state tests (#618)
Covers the POP3 TRANSACTION state machine via scripted sessions over
net.Pipe, mirroring the SMTP handler test harness: STAT/LIST/UIDL/DELE/
RETR/TOP/NOOP/RSET/QUIT including argument error paths, deleted-message
behavior, dot-stuffing, RETR/TOP source error paths, mailbox load
errors, session read error handling (idle timeout vs other), and the
TCP listener lifecycle (Start/serve/Notify/Drain).

No production code changes; current behavior is pinned as-is. Two
known RFC deviations are documented in tests (RETR/TOP of messages
marked deleted) for follow-up fixes. Package coverage: 24.4% -> 93.2%.

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-16 13:16:35 -07:00
James Hillyerd fc8a78b4fa bump ui deps (#617)
* bump ui deps

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

* bump more

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

---------

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-15 15:52:05 -07:00
James Hillyerd 9663b06225 fix: outdated swaks syntax, missing DecodeHeader failure logging (#616)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-08 12:03:14 -07:00
James Hillyerd f54715ee57 bump go deps (#615)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-07 15:03:28 -07:00
dependabot[bot] b67cea3c0a build(deps): bump actions/setup-go from 6 to 7 (#614)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  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>
2026-09-07 14:45:00 -07:00
dependabot[bot] 3f6afaad10 build(deps): bump actions/setup-node from 6 to 7 (#613)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  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>
2026-09-07 14:34:02 -07:00
dependabot[bot] ce9946a682 build(deps): bump actions/checkout from 6 to 7 (#612)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [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/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  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>
2026-09-07 14:33:38 -07:00
dependabot[bot] 99790dbd9f build(deps): bump lodash from 4.17.21 to 4.18.1 in /ui (#604)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 20:52:05 +00:00
dependabot[bot] 151fcd4e70 build(deps): bump picomatch from 2.3.1 to 2.3.2 in /ui (#603)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 20:52:02 +00:00
dependabot[bot] 2221fb4919 build(deps): bump docker/build-push-action from 6 to 7 (#601)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  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>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 13:21:18 -07:00
dependabot[bot] 19691e9d97 build(deps): bump docker/login-action from 3 to 4 (#600)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  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>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 13:20:59 -07:00
dependabot[bot] 74cde72786 build(deps): bump docker/metadata-action from 5 to 6 (#599)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  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>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 13:20:25 -07:00
dependabot[bot] e55882dc5a build(deps): bump docker/setup-buildx-action from 3 to 4 (#598)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  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>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 13:20:04 -07:00
dependabot[bot] e84b0d53d6 build(deps): bump docker/setup-qemu-action from 3 to 4 (#602)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  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>
Co-authored-by: inbucket-hillyerd-bot[bot] <325767861+inbucket-hillyerd-bot[bot]@users.noreply.github.com>
2026-09-06 13:14:43 -07:00
James Hillyerd c41af3f5e3 fix(nix): use top-level node-gyp/yarn and nodejs_22 in shell.nix (#606)
nodePackages was removed from nixpkgs; node-gyp and yarn are now
top-level packages. Also switch nodejs_20 to nodejs_22, as
nodejs-20.20.2 is now marked insecure and fails to build without
permittedInsecurePackages.

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-06 11:20:49 -07:00
James Hillyerd e56c6d5f83 Revert "build(deps): bump svgo from 2.8.0 to 2.8.2 in /ui (#596)" (#597)
This reverts commit 5fec537c16.
2026-03-11 10:36:54 -07:00
dependabot[bot] 5fec537c16 build(deps): bump svgo from 2.8.0 to 2.8.2 in /ui (#596)
Bumps [svgo](https://github.com/svg/svgo) from 2.8.0 to 2.8.2.
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v2.8.0...v2.8.2)

---
updated-dependencies:
- dependency-name: svgo
  dependency-version: 2.8.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 19:16:46 -08:00
dependabot[bot] 6bfeb91955 build(deps): bump minimatch from 3.1.2 to 3.1.5 in /ui (#595)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 09:05:46 -08:00
dependabot[bot] 53f4f0091e build(deps): bump goreleaser/goreleaser-action from 6 to 7 (#594)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: '7'
  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>
2026-03-03 09:05:04 -08:00
James Hillyerd be7d29c5d5 chore: Central go-version for GitHub actions (#591)
* chore: Init AGENTS.md

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

* chore: Central go-version for GitHub actions

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: Web server now uses Listen with context

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

* fix: replace interface{} with any

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

---------

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

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

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

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

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

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

* Fix new lint warnings

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

---------

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

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

* Fix lint warnings for loopvars

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

---------

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

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

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

* chore: update BeforeMailAccepted to use SMTPSession

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

---------

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

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

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

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

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

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

* Assert labels

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

---------

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

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

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

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

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

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

* err lint fix

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

---------

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

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

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

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

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

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

* chore: require NoError in integration test setup

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

---------

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

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

* chore: migrate integration to testify/suite

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

---------

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

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

* chore: extract long case into parseMailCmd func

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

* chore: remove extraneous braces in cases

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

---------

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

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

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

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

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

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

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

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

* chore: reorder smtp/handler test helpers

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

---------

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

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

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

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

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

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

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

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

* cmd/client: pass context

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

---------

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

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

* fix a number of typos

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

* fix many cosmetic linter warnings

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

---------

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Corey Aloia <corey.aloia@sap.com>
2024-02-09 08:53:44 -08:00
James Hillyerd 975cb4ca5e CHANGELOG: tweak release process (#473)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2024-02-05 13:06:27 -08:00
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
146 changed files with 12853 additions and 10116 deletions
+1
View File
@@ -0,0 +1 @@
use nix
+3 -2
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
View File
@@ -0,0 +1,7 @@
---
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+58 -10
View File
@@ -1,21 +1,24 @@
name: Build and Test
on:
push:
branches:
- main
pull_request:
jobs:
go-build:
linux-go-build:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ '1.17', '1.16' ]
name: Go ${{ matrix.go }} build
name: Linux Go build
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v2
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
go-version-file: '.go-version'
check-latest: true
- name: Build and test
run: |
go build ./...
@@ -24,10 +27,55 @@ jobs:
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
flag-name: Go-${{ matrix.go }}
flag-name: Linux-Go
parallel: true
windows-go-build:
runs-on: windows-latest
name: Windows Go build
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: '.go-version'
- 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@v7
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
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: go-build
needs:
- linux-go-build
- windows-go-build
name: Test Coverage
runs-on: ubuntu-latest
steps:
+35 -13
View File
@@ -1,18 +1,30 @@
name: Docker Image
on:
push:
branches: [ "develop" ]
tags: [ "v*" ]
pull_request:
branches:
- main
tags:
- 'v*'
pull_request_review:
types:
- submitted
workflow_dispatch: # allow for manual run
env:
REGISTRY_PUSH: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
jobs:
build:
name: 'Build Container'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v7
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v6
with:
images: |
inbucket/inbucket
@@ -22,28 +34,38 @@ jobs:
type=ref,event=pr
type=semver,pattern={{version}}
type=sha
type=edge,branch=develop
type=edge,branch=main
flavor: |
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64, linux/arm/v7
push: ${{ env.REGISTRY_PUSH == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+17
View File
@@ -0,0 +1,17 @@
name: Lint Go Code
on:
push:
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: '.go-version'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
+29 -18
View File
@@ -1,44 +1,55 @@
name: Build and Release
on:
push:
branches: [ "master", "develop" ]
tags: [ "v*" ]
pull_request:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
jobs:
release:
name: 'Go Releaser'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v2
uses: actions/setup-go@v7
with:
go-version: 1.17
go-version-file: '.go-version'
check-latest: true
- name: Setup Node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v7
with:
node-version: '14.x'
- name: Setup Elm
uses: jorelali/setup-elm@v2
with:
elm-version: 0.19.1
node-version: '20.x'
cache: 'yarn'
cache-dependency-path: ui/yarn.lock
- name: Build frontend
run: |
npm ci
npm run build
yarn install --frozen-lockfile --non-interactive
yarn run build
working-directory: ./ui
- name: Test build release
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v7
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
distribution: goreleaser
version: "~> v2"
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v7
if: "startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
distribution: goreleaser
version: "~> v2"
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+13
View File
@@ -3,6 +3,9 @@
*.a
*.so
# Emacs messiness.
*~
# Folders
_obj
_test
@@ -30,6 +33,8 @@ tags.*
# Desktop Services Store on macOS
.DS_Store
/.direnv
# Inbucket binaries
/client
/client.exe
@@ -52,3 +57,11 @@ repl-temp-*
/ui/dist/
# Dependency directories
/ui/node_modules
/ui/.parcel-cache
# Test lua files
/inbucket.lua
# IntelliJ
.idea
inbucket.iml
+1
View File
@@ -0,0 +1 @@
1.25
+82
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$
+19 -19
View File
@@ -1,3 +1,4 @@
version: 2 # goreleaser version
project_name: inbucket
release:
@@ -6,12 +7,6 @@ release:
name: inbucket
name_template: '{{.Tag}}'
brews:
- commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: bin.install ""
before:
hooks:
- go mod download
@@ -28,8 +23,13 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- id: inbucket-client
@@ -43,26 +43,29 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/client
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archives:
- id: tarball
format: tar.gz
formats: tar.gz
wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides:
- goos: windows
format: zip
formats: zip
files:
- LICENSE*
- README*
- CHANGELOG*
- etc/**/*
- ui/dist/**/*
- etc/**
- ui/dist/**
- ui/greeting.html
nfpms:
@@ -75,8 +78,8 @@ nfpms:
description: All-in-one disposable webmail service.
license: MIT
contents:
- src: "ui/dist/**/*"
dst: "/usr/local/share/inbucket/ui"
- src: "ui/dist/**"
dst: "/usr/share/inbucket/ui"
- src: "etc/linux/inbucket.service"
dst: "/lib/systemd/system/inbucket.service"
type: config|noreplace
@@ -84,9 +87,6 @@ nfpms:
dst: "/etc/inbucket/greeting.html"
type: config|noreplace
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
+9
View File
@@ -0,0 +1,9 @@
{
"runtime.version": "Lua 5.1",
"diagnostics": {
"globals": [
"inbucket",
"smtp"
]
}
}
+293
View File
@@ -0,0 +1,293 @@
# AGENTS.md - Inbucket
Guidance for AI agents working in this codebase.
## Project Overview
Inbucket is an email testing service that accepts messages for any email address and makes them available via web, REST, and POP3 interfaces. It's a self-contained Go application with an Elm-based web UI.
**Tech Stack:**
- Backend: Go 1.25+
- Frontend: Elm 0.19.1 with Parcel bundler
- Logging: zerolog
- Testing: testify (assert/require/suite), goldiff for golden file tests
- HTTP Router: gorilla/mux
- Configuration: envconfig (environment variables)
- Optional: Lua scripting for extensions (gopher-lua)
## Essential Commands
### Build
```bash
# Build Go binaries (inbucket server + client CLI)
make build
# Or build directly
go build ./cmd/inbucket
go build ./cmd/client
# Build UI (required before running server)
cd ui && yarn install && yarn build
```
### Test
```bash
# Run all Go tests with race detection
make test
# or
go test -race ./...
# Run tests for a specific package
go test -race ./pkg/storage/...
# Run tests with coverage
go test -race -coverprofile=profile.cov ./...
```
### Lint
```bash
# CI uses golangci-lint
golangci-lint run
# Make's lint target (older, uses golint)
make lint
```
### Run Development Server
```bash
# Build everything first
make build
cd ui && yarn build && cd ..
# Run with dev config
./etc/dev-start.sh
# Or run directly with defaults
./inbucket
```
Default ports:
- Web UI: http://localhost:9000
- SMTP: localhost:2500
- POP3: localhost:1100
### UI Development
```bash
cd ui
# Install dependencies
yarn install
# Development server with HMR (proxies to Go backend)
yarn start
# Production build
yarn build
# Clean build artifacts
yarn clean
```
## Code Organization
```
cmd/
inbucket/ # Main server binary
client/ # CLI client for REST API
pkg/
config/ # Environment-based configuration
extension/ # Lua extension system
luahost/ # Lua VM pool and bindings
event/ # Extension event types
message/ # Message manager (storage abstraction)
metric/ # Expvar metrics
msghub/ # Real-time message pub/sub
policy/ # Email address/domain policies
rest/ # REST API v1/v2 controllers
client/ # Go client library for REST API
model/ # JSON API models
server/
smtp/ # SMTP server
pop3/ # POP3 server
web/ # HTTP server, handlers, helpers
storage/ # Storage interface and implementations
file/ # File-based storage
mem/ # In-memory storage
stringutil/ # String utilities
test/ # Test utilities and integration tests
webui/ # Web UI controllers
ui/
src/
Main.elm # Elm app entry point
Api.elm # API client
Page/ # Page modules (Home, Mailbox, Monitor, Status)
Data/ # Data models
tests/ # Elm tests
```
## Configuration
Inbucket uses environment variables for all configuration. Key variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `INBUCKET_LOGLEVEL` | `info` | debug, info, warn, error |
| `INBUCKET_MAILBOXNAMING` | `local` | local, full, or domain |
| `INBUCKET_SMTP_ADDR` | `0.0.0.0:2500` | SMTP listen address |
| `INBUCKET_WEB_ADDR` | `0.0.0.0:9000` | HTTP listen address |
| `INBUCKET_POP3_ADDR` | `0.0.0.0:1100` | POP3 listen address |
| `INBUCKET_STORAGE_TYPE` | `memory` | `memory` or `file` |
| `INBUCKET_WEB_UIDIR` | `ui/dist` | Path to built UI files |
Run `./inbucket -help` for complete list.
See `doc/config.md` for detailed documentation.
## Code Patterns
### Error Handling
- Use zerolog for structured logging
- Return errors up the call stack; log at the top level
- Use `github.com/pkg/errors` patterns for wrapping
### HTTP Handlers
Handlers follow this pattern in `pkg/server/web/`:
```go
func Handler(f func(http.ResponseWriter, *http.Request, *Context) error) http.Handler
```
Controllers return errors; the wrapper handles HTTP responses.
### Storage Interface
New storage backends implement `storage.Store` interface (`pkg/storage/storage.go`):
```go
type Store interface {
AddMessage(message Message) (id string, err error)
GetMessage(mailbox, id string) (Message, error)
GetMessages(mailbox string) ([]Message, error)
MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error
RemoveMessage(mailbox, id string) error
VisitMailboxes(f func([]Message) (cont bool)) error
}
```
Register in `cmd/inbucket/main.go` init():
```go
storage.Constructors["mytype"] = mystore.New
```
### JSON Tag Convention
JSON fields use kebab-case (configured in `.golangci.yml` tagliatelle):
```go
type Example struct {
FieldName string `json:"field-name"`
}
```
### Elm Architecture
The UI follows The Elm Architecture:
- `Main.elm` - App shell, routing
- `Page/*.elm` - Page modules with Model, Msg, init, update, view
- `Data/*.elm` - Data types and JSON decoders
- `Api.elm` - HTTP client for REST API
## Testing
### Test Structure
- Unit tests: alongside source files (`*_test.go`)
- Integration tests: `pkg/test/integration_test.go`
- Test utilities: `pkg/test/`
### Test Frameworks
- Standard `testing` package
- `github.com/stretchr/testify/assert` - assertions
- `github.com/stretchr/testify/require` - fatal assertions
- `github.com/stretchr/testify/suite` - test suites
- `github.com/jhillyerd/goldiff` - golden file testing
### Test Utilities
Located in `pkg/test/`:
- `StoreStub`, `ManagerStub` - mock implementations
- `DeliverToStore()` - create test messages
- `StoreSuite()` - table-driven storage tests
- `NewLuaState()` - Lua testing helper
### Golden File Tests
Input in `pkg/test/testdata/*.txt`, expected output in `*.golden`:
```go
goldiff.File(t, got, "testdata", "basic.golden")
```
### Running Specific Tests
```bash
# Run tests matching pattern
go test -race -run TestIntegration ./pkg/test/
# Run with verbose output
go test -race -v ./pkg/storage/mem/
# Run storage suite for specific implementation
go test -race -run TestMemStore ./pkg/storage/mem/
```
## CI/CD
GitHub Actions workflows in `.github/workflows/`:
- `build-and-test.yml` - Build and test on Linux/Windows, coverage to coveralls
- `lint.yml` - golangci-lint
- `docker-build.yml` - Docker image builds
- `release.yml` - goreleaser for releases
## Important Gotchas
1. **UI must be built before running server** - The Go server serves static files from `ui/dist/`
2. **Storage type affects persistence** - `memory` storage loses all data on restart; use `file` for persistence
3. **Port conflicts** - Default ports (9000, 2500, 1100) may conflict with other services
4. **Lua scripting is optional** - If `inbucket.lua` is not present, the server runs without extensions
5. **Test coverage requires race detector** - CI always runs with `-race`
6. **golangci-lint v2 config** - Uses v2 format in `.golangci.yml`
7. **Windows paths in storage** - Use `$` instead of `:` in file storage paths (e.g., `D$/inbucket`)
## REST API
Base URL: `http://localhost:9000/api/`
### API v1 Endpoints
- `GET /v1/mailbox/{name}` - List messages
- `GET /v1/mailbox/{name}/{id}` - Get message
- `PATCH /v1/mailbox/{name}/{id}` - Mark as seen
- `DELETE /v1/mailbox/{name}` - Purge mailbox
- `DELETE /v1/mailbox/{name}/{id}` - Delete message
- `GET /v1/mailbox/{name}/{id}/source` - Get raw source
### API v2 Endpoints
- `GET /v2/monitor/messages` - WebSocket for real-time messages
Go client available: `github.com/inbucket/inbucket/v3/pkg/rest/client`
## Development Tips
1. **Quick iteration** - Use `make reflex` for auto-rebuild on Go file changes
2. **UI development** - Run `yarn start` in `ui/` for HMR; it proxies API requests to the Go server
3. **Debug network** - Run with `-netdebug` flag to dump SMTP/POP3 traffic
4. **Test email sending** - Use swaks or the test scripts in `etc/swaks-tests/`
5. **Check configuration** - Run `./inbucket -help` to see all env vars and defaults
+151 -9
View File
@@ -7,6 +7,134 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v3.2.0] - 2026-10-10
### Added
- Reload TLS certificates when their files change (#625)
### Fixed
- pop3: Track STLS state per session instead of per server (#626)
- pop3: Reject deleted messages and failed TLS handshakes (#620)
- rest: Make WebSocket monitor listener teardown safe under concurrency (#623)
- Fix outdated swaks syntax and log DecodeHeader failures (#616)
### Changed
- Update Go and UI dependencies (#615, #617, #631)
- GitHub Actions: bump checkout, setup-go, setup-node, and Docker actions (#612, #613, #614, #598, #599, #600, #601, #602)
## [v3.1.1] - 2025-12-06
### Fixed
- Go version update for CVE-2025-47907
- Removed broken Windows arm7 build (#589)
## [v3.1.0] - 2025-07-27
### Added
- Note in logs that a missing Lua script is not an error (#575)
### Fixed
- Accept and handle emails sent with an empty 821.From (#561)
## [v3.1.0-beta3] - 2024-11-02
### Added
- Lua scripting additions:
- Add `SMTPSession` and `BeforeRcptToAccepted` event (#541)
- Add `SMTPResponse` type for extensions (#539)
- Add `RemoteAddr` to `SMTPSession` (#548)
- Context support for REST client (#496)
### Fixed
- Rename Lua `BeforeMailAccepted`, change args (#547)
- pop3: Prevent STLS cmd triggered crashes (#516)
- ui: date-format version, fixes yarn build (#508)
- rework client example to omit `log.Fatal`, breaks defer (#489)
- Rest Client: Allow relative URLs (#477)
## [v3.1.0-beta2] - 2024-02-05
### Added
- 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.
@@ -273,8 +401,20 @@ No change from beta1.
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.2.0...main
[v3.2.0]: https://github.com/inbucket/inbucket/compare/v3.1.1...v3.2.0
[v3.1.1]: https://github.com/inbucket/inbucket/compare/v3.1.0...v3.1.1
[v3.1.0]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta3...v3.1.0
[v3.1.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...v3.1.0-beta3
[v3.1.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...v3.1.0-beta2
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
[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
@@ -297,16 +437,18 @@ No change from beta1.
## Release Checklist
1. Create release branch: `git flow release start 1.x.0`
1. Create a release branch
2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Rename *Unreleased* section to release name and date
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
3. Run tests
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
complete
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.
+6 -19
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
+26 -27
View File
@@ -1,49 +1,48 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Install build-time dependencies
FROM golang:1.17-alpine3.14 as builder
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 AS frontend
RUN npm install -g node-gyp
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
RUN make clean deps
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm ci
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
RUN rm -rf .parcel-cache dist elm-stuff node_modules
RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
# Build server
### 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
# Build frontend
WORKDIR /build/ui
RUN npm run build
# Run in minimal image
FROM alpine:3.14
### 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=builder /build/ui/dist ui
COPY --from=backend /build/inbucket bin
COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY etc/docker/defaults/start-inbucket.sh /
# 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_SMTP_DISCARDDOMAINS=bitbucket.local
ENV INBUCKET_SMTP_TIMEOUT=30s
ENV INBUCKET_POP3_TIMEOUT=30s
ENV INBUCKET_WEB_GREETINGFILE=/config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY=secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR=ui
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
ENV INBUCKET_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'
+4 -1
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:
+20 -13
View File
@@ -1,7 +1,7 @@
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/inbucket/inbucket.png?branch=master)][Build Status]
[![Docker Image](https://github.com/inbucket/inbucket/workflows/Docker%20Image/badge.svg)][Docker Image]
![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 interfaces. Once
@@ -26,10 +26,17 @@ to contribute code to the project check out [CONTRIBUTING.md].
## Docker
Inbucket has automated [Docker Image] builds via Docker Hub. The `stable` tag
tracks our `master` branch (releases), `latest` tracks our unstable
`development` branch.
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
tracks our tagged releases, and `edge` tracks our potentially unstable
`main` branch.
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
@@ -38,8 +45,8 @@ You will need functioning [Go] and [Node.js] installations for this to work.
```sh
git clone https://github.com/inbucket/inbucket.git
cd inbucket/ui
npm ci
npm run build
yarn install
yarn build
cd ..
go build ./cmd/inbucket
```
@@ -72,12 +79,12 @@ Inbucket is open source software released under the MIT License. The latest
version can be found at https://github.com/inbucket/inbucket
[Build Status]: https://travis-ci.org/inbucket/inbucket
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
[config.md]: https://github.com/inbucket/inbucket/blob/master/doc/config.md
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
[Configurator]: https://www.inbucket.org/configurator/
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
[Docker Image]: https://www.inbucket.org/binaries/docker.html
[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/
+7 -8
View File
@@ -6,12 +6,10 @@ import (
"fmt"
"github.com/google/subcommands"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
type listCmd struct {
mailbox string
}
type listCmd struct{}
func (*listCmd) Name() string {
return "list"
@@ -27,22 +25,23 @@ func (*listCmd) Usage() string {
`
}
func (l *listCmd) SetFlags(f *flag.FlagSet) {
}
func (l *listCmd) SetFlags(f *flag.FlagSet) {}
func (l *listCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup rest client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("REST call failed", err)
}
+6 -1
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 {
+17 -10
View File
@@ -10,13 +10,12 @@ import (
"time"
"github.com/google/subcommands"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
type matchCmd struct {
mailbox string
output string
outFunc func(headers []*client.MessageHeader) error
outFunc func(ctx context.Context, headers []*client.MessageHeader) error
delete bool
// match criteria
from regexFlag
@@ -52,11 +51,12 @@ func (m *matchCmd) SetFlags(f *flag.FlagSet) {
}
func (m *matchCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Select output function
switch m.output {
case "id":
@@ -68,16 +68,19 @@ func (m *matchCmd) Execute(
default:
return usage("unknown output type: " + m.output)
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
// Find matches
matches := make([]*client.MessageHeader, 0, len(headers))
for _, h := range headers {
@@ -85,24 +88,28 @@ func (m *matchCmd) Execute(
matches = append(matches, h)
}
}
// Return error status if no matches
if len(matches) == 0 {
return subcommands.ExitFailure
}
// Output matches
err = m.outFunc(matches)
err = m.outFunc(ctx, matches)
if err != nil {
return fatal("Error", err)
}
// Optionally, delete matches
if m.delete {
// Delete matches
for _, h := range matches {
err = h.Delete()
err = h.DeleteWithContext(ctx)
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
@@ -149,14 +156,14 @@ func (m *matchCmd) match(header *client.MessageHeader) bool {
return true
}
func outputID(headers []*client.MessageHeader) error {
func outputID(_ context.Context, headers []*client.MessageHeader) error {
for _, h := range headers {
fmt.Println(h.ID)
}
return nil
}
func outputJSON(headers []*client.MessageHeader) error {
func outputJSON(_ context.Context, headers []*client.MessageHeader) error {
jsonEncoder := json.NewEncoder(os.Stdout)
jsonEncoder.SetEscapeHTML(false)
jsonEncoder.SetIndent("", " ")
+20 -14
View File
@@ -7,12 +7,11 @@ import (
"os"
"github.com/google/subcommands"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
type mboxCmd struct {
mailbox string
delete bool
delete bool
}
func (*mboxCmd) Name() string {
@@ -34,48 +33,55 @@ func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
}
func (m *mboxCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
headers, err := c.ListMailboxWithContext(ctx, mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
err = outputMbox(headers)
err = outputMbox(ctx, headers)
if err != nil {
return fatal("Error", err)
}
// Optionally, delete retrieved messages
if m.delete {
// Delete matches
for _, h := range headers {
err = h.Delete()
err = h.DeleteWithContext(ctx)
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
// outputMbox renders messages in mbox format
// also used by match subcommand
func outputMbox(headers []*client.MessageHeader) error {
// outputMbox renders messages in mbox format.
// It is also used by match subcommand.
func outputMbox(ctx context.Context, headers []*client.MessageHeader) error {
for _, h := range headers {
source, err := h.GetSource()
source, err := h.GetSourceWithContext(ctx)
if err != nil {
return fmt.Errorf("Get source REST failed: %v", err)
return fmt.Errorf("get source REST failed: %v", err)
}
fmt.Printf("From %s\n", h.From)
// TODO Escape "From " in message bodies with >
source.WriteTo(os.Stdout)
if _, err := source.WriteTo(os.Stdout); err != nil {
return err
}
fmt.Println()
}
return nil
+42 -54
View File
@@ -14,19 +14,11 @@ import (
"syscall"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/server/pop3"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/server"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage/file"
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -45,7 +37,7 @@ func init() {
startTime.Set(time.Now().UnixNano() / 1000000)
// Goroutine count for status page.
expvar.Publish("goroutines", expvar.Func(func() interface{} {
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
@@ -57,6 +49,7 @@ func init() {
func main() {
// Command line flags.
help := flag.Bool("help", false, "Displays help on flags and env variables.")
versionflag := flag.Bool("version", false, "Displays version.")
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
@@ -72,6 +65,10 @@ func main() {
config.Usage()
return
}
if *versionflag {
fmt.Fprintln(os.Stdout, version)
return
}
// Process configuration.
config.Version = version
@@ -114,36 +111,16 @@ func main() {
}
}
// Configure internal services.
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
// Configure and start internal services.
svcCtx, svcCancel := context.WithCancel(context.Background())
services, err := server.FullAssembly(conf)
if err != nil {
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
removePIDFile(*pidfile)
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
}
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Configure routes and start HTTP server.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
services.Start(svcCtx, func() {
startupLog.Debug().Msg("All services report ready")
})
// Loop forever waiting for signals or shutdown channel.
signalLoop:
@@ -155,30 +132,37 @@ signalLoop:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
Msg("Received SIGINT, shutting down")
close(shutdownChan)
svcCancel()
break signalLoop
case syscall.SIGTERM:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
Msg("Received SIGTERM, shutting down")
close(shutdownChan)
svcCancel()
break signalLoop
}
case <-shutdownChan:
rootCancel()
case <-services.Notify():
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
svcCancel()
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
smtpServer.Drain()
pop3Server.Drain()
retentionScanner.Join()
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
services.SMTPServer.Drain()
log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
services.POP3Server.Drain()
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
services.RetentionScanner.Join()
removePIDFile(*pidfile)
closeLog()
}
// openLog configures zerolog output, returns func to close logfile.
func openLog(level string, logfile string, json bool) (close func(), err error) {
func openLog(level string, logfile string, json bool) (closeLog func(), err error) {
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
@@ -189,9 +173,10 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
return nil, fmt.Errorf("log level %q not one of: debug, info, warn, error", level)
}
close = func() {}
closeLog = func() {}
var w io.Writer
color := runtime.GOOS != "windows"
switch logfile {
@@ -207,21 +192,24 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
bw := bufio.NewWriter(logf)
w = bw
color = false
close = func() {
closeLog = func() {
_ = bw.Flush()
_ = logf.Close()
}
}
w = zerolog.SyncWriter(w)
if json {
log.Logger = log.Output(w)
return close, nil
return closeLog, nil
}
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: w,
NoColor: !color,
})
return close, nil
return closeLog, nil
}
// removePIDFile removes the PID file if created.
+42 -6
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
@@ -56,6 +58,16 @@ off with `warn` or `error`.
- Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error`
### Lua Script
`INBUCKET_LUA_PATH`
This is the path to the (optional) Inbucket Lua script. If the specified file
is present, Inbucket will load it during startup. Ignored if the file is not
found, or the setting is empty.
- Default: `inbucket.lua`
### Mailbox Naming
`INBUCKET_MAILBOXNAMING`
@@ -150,7 +162,7 @@ List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `localhost,mysite.org`
### Rejected Recipient Domain List
@@ -161,7 +173,22 @@ List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
has no effect when false.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `reject.com,gmail.com`
### Rejected Origin Domain List
`INBUCKET_SMTP_REJECTORIGINDOMAINS`
List of domains to reject mail from. This list is enforced regardless of the
`INBUCKET_SMTP_DEFAULTACCEPT` value.
Enforcement takes place during evalation of the `MAIL FROM` SMTP command, the
origin domain is extracted from the address presented and compared against the
list. It does not take email headers into account.
- Default: None
- Values: Comma separated list of origin domains
- Example: `reject.com,gmail.com`
### Default Recipient Store Policy
@@ -183,7 +210,7 @@ List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `localhost,mysite.org`
### Discarded Recipient Domain List
@@ -196,7 +223,7 @@ emails. Messages sent to a domain other than this will be stored normally.
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
- Default: None
- Values: Comma separated list of domains
- Values: Comma separated list of recipient domains
- Example: `recycle.com,loadtest.org`
### Network Idle Timeout
@@ -216,6 +243,14 @@ to the public internet.
Enable the STARTTLS option for opportunistic TLS support
Inbucket checks the certificate and private key files on each full TLS handshake
and reloads them when either file changes, so renewed certificates (for example
from cert-manager) take effect without a restart. Clients that resume an
earlier TLS session keep that session's certificate until their next full
handshake. If a reload fails, Inbucket logs a warning, keeps serving the
previous certificate, and tries again when the files change or after 30 seconds.
The same applies to the POP3 TLS files.
- Default: `false`
- Values: `true` or `false`
@@ -415,7 +450,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
+2 -1
View File
@@ -3,6 +3,7 @@
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
#export INBUCKET_MAILBOXNAMING="domain"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
@@ -28,7 +29,7 @@ fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'npm run build' from the 'ui' directory." >&2
echo "Run 'yarn build' from the 'ui' directory." >&2
exit 1
fi
+4 -2
View File
@@ -13,5 +13,7 @@ of 300 messages per mailbox - the oldest messages will be deleted to stay under
that limit.</p>
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
domain will be accepted but not written to disk. Use this domain for load or
soak testing your application.</p>
domain will be accepted, but immediately <b>discarded</b> without being written
to disk. Use this domain for load or soak testing your application. Inbucket
will retain mail for any other domain by default, i.e.
<code>@inbucket.local</code>.</p>
+15 -3
View File
@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image
# Docker Image Tag
IMAGE="inbucket/inbucket"
IMAGE="inbucket/inbucket:edge"
# Ports exposed on host:
PORT_HTTP=9000
@@ -25,6 +25,9 @@ main() {
usage
exit
;;
-b)
build
;;
-r)
reset
;;
@@ -38,6 +41,8 @@ main() {
esac
done
set -x
docker run $run_opts \
-p $PORT_HTTP:9000 \
-p $PORT_SMTP:2500 \
@@ -49,14 +54,21 @@ main() {
usage() {
echo "$0 [options]" 2>&1
echo " -b build - build image before starting" 2>&1
echo " -d detach - detach and print container ID" 2>&1
echo " -r reset - purge config and data before startup" 2>&1
echo " -h help - print this message" 2>&1
}
build() {
echo "Building $IMAGE"
docker build . -t "$IMAGE"
echo
}
reset() {
/bin/rm -rf "$VOL_CONFIG"
/bin/rm -rf "$VOL_DATA"
rm -rf "$VOL_CONFIG"
rm -rf "$VOL_DATA"
}
main $*
+6 -6
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
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# rest-apiv1.sh
# description: Script to access Inbucket REST API version 1
+43
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--
+20 -13
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# run-tests.sh
# description: Generate test emails for Inbucket
@@ -24,38 +24,45 @@ 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
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
swaks $* --h-Subject: "Swaks Plain Text" --body @text.txt
# Multi-recipient test
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
--body text.txt
--body @text.txt
# HTML test
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
swaks $* --h-Subject: "Swaks HTML" --data @mime-html.raw
# Top level HTML test
swaks $* --h-Subject: "Swaks Top Level HTML" --data nonmime-html.raw
swaks $* --h-Subject: "Swaks Top Level HTML" --data @nonmime-html.raw
# Attachment test
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
--body text.txt
--body @text.txt
# Encoded subject line test
swaks $* --data utf8-subject.raw
swaks $* --data @utf8-subject.raw
# Gmail test
swaks $* --data gmail.raw
swaks $* --data @gmail.raw
# Outlook test
swaks $* --data outlook.raw
swaks $* --data @outlook.raw
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw
swaks $* --data @nonmime-html-responsive.raw
swaks $* --data @nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw
swaks $* --data @mime-errors.raw
# IP RCPT domain
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body @text.txt
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body @text.txt
# Inline attachment test
swaks $* --data @mime-inline.raw
+40 -14
View File
@@ -1,21 +1,47 @@
module github.com/inbucket/inbucket
module github.com/inbucket/inbucket/v3
go 1.25.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
github.com/cosmotek/loguago v1.0.0
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/jhillyerd/enmime v0.9.2
github.com/gorilla/css v1.0.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/inbucket/gopher-json v0.2.0
github.com/jhillyerd/enmime/v2 v2.5.0
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.15
github.com/rs/zerolog v1.23.0
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
github.com/microcosm-cc/bluemonday v1.0.27
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.12.1
github.com/yuin/gopher-lua v1.1.2
golang.org/x/net v0.58.0
)
go 1.13
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/goccy/go-json v0.11.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/inbucket/html2text v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.1.9 // indirect
github.com/olekukonko/tablewriter v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+64 -76
View File
@@ -1,90 +1,78 @@
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
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/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cosmotek/loguago v1.0.0 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
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/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.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/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
github.com/jhillyerd/enmime/v2 v2.5.0 h1:V1A3k6CpsAVzTqh5pTBUzOFatHc2M1YAETgVatItrlk=
github.com/jhillyerd/enmime/v2 v2.5.0/go.mod h1:X9df0EHDdktxxYTp2jFFgeucaNWadFKv0qW3UmA+R70=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.15 h1:J4uN+qPng9rvkBZBoBb8YGR+ijuklIMpSOZZLjYpbeY=
github.com/microcosm-cc/bluemonday v1.0.15/go.mod h1:ZLvAzeakRwrGnzQEvstVzVt3ZpqOF2+sdFr0Om+ce30=
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/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
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/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.9 h1:7twe+xnE6CSlfOlvGj0Finwnvr3CgWt25W33yuWNI6k=
github.com/olekukonko/ll v0.1.9/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.5 h1:4LoZSfMySpMQY3PT8RWJsJeuEuMIoo9xGRgvmqjg6IQ=
github.com/olekukonko/tablewriter v1.1.5/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.23.0 h1:UskrK+saS9P9Y789yNNulYKdARjPZuS35B8gJF2x60g=
github.com/rs/zerolog v1.23.0/go.mod h1:6c7hFfxPOy7TacJc4Fcdi24/J0NKYGzjG8FWRI916Qo=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
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.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+36 -23
View File
@@ -8,7 +8,7 @@ import (
"text/tabwriter"
"time"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
@@ -51,46 +51,58 @@ func (n *mbNaming) Decode(v string) error {
case "domain":
*n = DomainNaming
default:
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
return fmt.Errorf("unknown MailboxNaming strategy: %q", v)
}
return nil
}
// Root contains global configuration, and structs with for specific sub-systems.
type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
Lua Lua
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
SMTP SMTP
POP3 POP3
Web Web
Storage Storage
}
// Lua contains the Lua extension host configuration.
type Lua struct {
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
}
// SMTP contains the SMTP server configuration.
type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
RejectOriginDomains []string `desc:"Domains to reject mail from"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
ForceTLS bool `default:"false" desc:"Listen for connections with TLS."`
}
// POP3 contains the POP3 server configuration.
type POP3 struct {
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
TLSEnabled bool `default:"false" desc:"Enable TLS"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
}
// Web contains the HTTP server configuration.
@@ -122,6 +134,7 @@ func Process() (*Root, error) {
stringutil.SliceToLower(c.SMTP.RejectDomains)
stringutil.SliceToLower(c.SMTP.StoreDomains)
stringutil.SliceToLower(c.SMTP.DiscardDomains)
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
return c, err
}
+89
View File
@@ -0,0 +1,89 @@
package extension
import (
"errors"
"sync"
"time"
)
// AsyncEventBroker maintains a list of listeners interested in a specific type
// of event. Events are sent in parallel to all listeners, and no result is
// returned.
type AsyncEventBroker[E any] struct {
sync.RWMutex
listenerNames []string // Ordered listener names.
listenerFuncs []func(E) // Ordered listener functions.
}
// Emit sends the provided event to each registered listener in parallel.
func (eb *AsyncEventBroker[E]) Emit(event *E) {
eb.RLock()
defer eb.RUnlock()
for _, l := range eb.listenerFuncs {
// Events are copied to minimize the risk of mutation.
go l(*event)
}
}
// AddListener registers the named listener, replacing one with a duplicate
// name if present. Listeners should be added in order of priority, most
// significant first.
func (eb *AsyncEventBroker[E]) AddListener(name string, listener func(E)) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
eb.listenerNames = append(eb.listenerNames, name)
eb.listenerFuncs = append(eb.listenerFuncs, listener)
}
// RemoveListener unregisters the named listener.
func (eb *AsyncEventBroker[E]) RemoveListener(name string) {
eb.Lock()
defer eb.Unlock()
eb.lockedRemoveListener(name)
}
func (eb *AsyncEventBroker[E]) lockedRemoveListener(name string) {
for i, entry := range eb.listenerNames {
if entry == name {
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
break
}
}
}
// AsyncTestListener returns a func that will wait for an event and return it, or timeout
// with an error.
func (eb *AsyncEventBroker[E]) AsyncTestListener(name string, capacity int) func() (*E, error) {
// Send event down channel.
events := make(chan E, capacity)
eb.AddListener(name,
func(msg E) {
events <- msg
})
count := 0
return func() (*E, error) {
count++
defer func() {
if count >= capacity {
eb.RemoveListener(name)
close(events)
}
}()
select {
case event := <-events:
return &event, nil
case <-time.After(time.Second * 2):
return nil, errors.New("timeout waiting for event")
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package extension_test
import (
"testing"
"time"
"github.com/inbucket/inbucket/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
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
}
}
}
+134
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")
}
+56
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
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{}}
}
+92
View File
@@ -0,0 +1,92 @@
package luahost
import (
"net/mail"
lua "github.com/yuin/gopher-lua"
)
const mailAddressName = "address"
func registerMailAddressType(ls *lua.LState) {
mt := ls.NewTypeMetatable(mailAddressName)
ls.SetGlobal(mailAddressName, mt)
// Static attributes.
ls.SetField(mt, "new", ls.NewFunction(newMailAddress))
// Methods.
ls.SetField(mt, "__index", ls.NewFunction(mailAddressIndex))
ls.SetField(mt, "__newindex", ls.NewFunction(mailAddressNewIndex))
}
func newMailAddress(ls *lua.LState) int {
val := &mail.Address{
Name: ls.CheckString(1),
Address: ls.CheckString(2),
}
ud := wrapMailAddress(ls, val)
ls.Push(ud)
return 1
}
func wrapMailAddress(ls *lua.LState, val *mail.Address) *lua.LUserData {
ud := ls.NewUserData()
ud.Value = val
ls.SetMetatable(ud, ls.GetTypeMetatable(mailAddressName))
return ud
}
func unwrapMailAddress(ud *lua.LUserData) (*mail.Address, bool) {
val, ok := ud.Value.(*mail.Address)
return val, ok
}
func checkMailAddress(ls *lua.LState, pos int) *mail.Address {
ud := ls.CheckUserData(pos)
if val, ok := ud.Value.(*mail.Address); ok {
return val
}
ls.ArgError(1, mailAddressName+" expected")
return nil
}
// Gets a field value from MailAddress user object. This emulates a Lua table,
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
func mailAddressIndex(ls *lua.LState) int {
a := checkMailAddress(ls, 1)
field := ls.CheckString(2)
// Push the requested field's value onto the stack.
switch field {
case "name":
ls.Push(lua.LString(a.Name))
case "address":
ls.Push(lua.LString(a.Address))
default:
// Unknown field.
ls.Push(lua.LNil)
}
return 1
}
// Sets a field value on MailAddress user object. This emulates a Lua table,
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
func mailAddressNewIndex(ls *lua.LState) int {
a := checkMailAddress(ls, 1)
index := ls.CheckString(2)
switch index {
case "name":
a.Name = ls.CheckString(3)
case "address":
a.Address = ls.CheckString(3)
default:
ls.RaiseError("invalid index %q", index)
}
return 0
}
@@ -0,0 +1,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)
}
@@ -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
}
@@ -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)
}
+228
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
}
+102
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))
}
+120
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
}
@@ -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)
}
@@ -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())
}
@@ -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",
})
}
+72
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
}
@@ -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))
}
+259
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
}
+303
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)
}
+113
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
}
+102
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())
}
+104 -58
View File
@@ -2,29 +2,32 @@ package message
import (
"bytes"
"fmt"
"io"
"net/mail"
"strings"
"time"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/jhillyerd/enmime"
"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,117 @@ 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
logger.Error().Err(err).Msg("Failed to parse message headers")
return err
}
fromaddr, err := env.AddressList("From")
if err != nil || len(fromaddr) == 0 {
fromaddr = []*mail.Address{{Address: from}}
fromAddrs, err := enmime.ParseAddressList(header.Get("From"))
if err != nil || len(fromAddrs) == 0 {
// Failed to parse From header, use SMTP MAIL FROM instead.
fromAddrs = make([]*mail.Address, 1)
fromAddrs[0] = &from.Address
}
toaddr, err := env.AddressList("To")
toAddrs, err := enmime.ParseAddressList(header.Get("To"))
if err != nil {
toaddr = make([]*mail.Address, len(recipients))
// Failed to parse To header, use SMTP RCPT TO instead.
toAddrs = make([]*mail.Address, len(recipients))
for i, torecip := range recipients {
toaddr[i] = &torecip.Address
toAddrs[i] = &torecip.Address
}
}
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
delivery := &Delivery{
Meta: Metadata{
Mailbox: to.Mailbox,
From: fromaddr[0],
To: toaddr,
Date: time.Now(),
Subject: env.GetHeader("Subject"),
},
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
subject := header.Get("Subject")
now := time.Now()
tstamp := now.UTC().Format(recvdTimeFmt)
// Process inbound message through extensions.
mailboxes := make([]string, 0, len(recipients))
for _, recip := range recipients {
mailboxes = append(mailboxes, recip.Mailbox)
}
id, err := s.Store.AddMessage(delivery)
if err != nil {
return "", err
// Construct InboundMessage event and process through extensions.
inbound := &event.InboundMessage{
Mailboxes: mailboxes,
From: fromAddrs[0],
To: toAddrs,
Subject: subject,
Size: int64(len(source)),
}
if s.Hub != nil {
// Broadcast message information.
broadcast := msghub.Message{
Mailbox: to.Mailbox,
ID: id,
From: stringutil.StringAddress(delivery.From()),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),
Size: delivery.Size(),
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
if extResult == nil {
// Use address policy to determine deliverable mailboxes.
mailboxes = mailboxes[:0]
for _, recip := range recipients {
if recip.ShouldStore() {
mailboxes = append(mailboxes, recip.Mailbox)
}
}
s.Hub.Dispatch(broadcast)
inbound.Mailboxes = mailboxes
} else {
// Event response overrides destination mailboxes and address policy.
inbound = extResult
}
return id, nil
// Deliver to each mailbox.
for _, mb := range inbound.Mailboxes {
// Append recipient and timestamp to generated Received header.
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
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 +170,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 +205,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
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)
}
}
+12 -22
View File
@@ -3,44 +3,34 @@ package message
import (
"io"
"io/ioutil"
"net/mail"
"net/textproto"
"time"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/jhillyerd/enmime"
"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.
+83 -32
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)
}
+167 -42
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)
+134 -49
View File
@@ -2,12 +2,14 @@ package policy
import (
"bytes"
"errors"
"fmt"
"net"
"net/mail"
"strings"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
)
// Addressing handles email address policy.
@@ -17,44 +19,41 @@ type Addressing struct {
// ExtractMailbox extracts the mailbox name from a partial email address.
func (a *Addressing) ExtractMailbox(address string) (string, error) {
if a.Config.MailboxNaming == config.DomainNaming {
return extractDomainMailbox(address)
}
local, domain, err := parseEmailAddress(address)
if err != nil {
return "", err
}
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
if a.Config.MailboxNaming == config.LocalNaming {
return local, nil
}
if a.Config.MailboxNaming == config.DomainNaming {
// If no domain is specified, assume this is being
// used for mailbox lookup via the API.
if domain == "" {
if ValidateDomainPart(local) == false {
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
}
return local, nil
}
if ValidateDomainPart(domain) == false {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
}
return domain, nil
}
if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
return "", fmt.Errorf("unknown MailboxNaming value: %v", a.Config.MailboxNaming)
}
if domain == "" {
return local, nil
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
return "", fmt.Errorf("domain part %q in %q failed validation", domain, address)
}
return local + "@" + domain, nil
}
// NewRecipient parses an address into a Recipient.
// NewRecipient parses an address into a Recipient. This is used for parsing RCPT TO arguments,
// not To headers.
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
@@ -64,12 +63,8 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
if err != nil {
return nil, err
}
ar, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
return &Recipient{
Address: *ar,
Address: mail.Address{Address: address},
addrPolicy: a,
LocalPart: local,
Domain: domain,
@@ -77,6 +72,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)
@@ -105,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.
@@ -114,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
}
@@ -122,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 := '.'
@@ -168,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'):
@@ -207,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 {
@@ -218,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 {
@@ -228,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 == '@':
@@ -253,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)
@@ -271,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
}
@@ -291,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]
+348 -17
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/policy"
)
func TestShouldAcceptDomain(t *testing.T) {
@@ -33,7 +33,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with default reject.
@@ -60,7 +59,6 @@ func TestShouldAcceptDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -90,7 +88,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with storage disabled.
@@ -117,7 +114,6 @@ func TestShouldStoreDomain(t *testing.T) {
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
@@ -259,29 +255,76 @@ func TestExtractMailboxValid(t *testing.T) {
full: "chars|}~@example.co.uk",
domain: "example.co.uk",
},
{
input: "@host:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "@a.com,@b.com:user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "u@[127.0.0.1]",
local: "u",
full: "u@[127.0.0.1]",
domain: "[127.0.0.1]",
},
{
input: "u@[IPv6:2001:db8:aaaa:1::100]",
local: "u",
full: "u@[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
}
for _, tc := range testTable {
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with local naming %q: %v", tc.input, err)
} else {
if result != tc.local {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
}
} else if result != tc.local {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
}
if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with full naming %q: %v", tc.input, err)
} else {
if result != tc.full {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
}
} else if result != tc.full {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
}
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
} else {
if result != tc.domain {
} else if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
}
}
// Test special cases with domain addressing mode.
func TestExtractDomainMailboxValid(t *testing.T) {
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
tests := map[string]struct {
input string // Input to test
domain string // Expected output when mailbox naming = domain
}{
"ipv4": {
input: "[127.0.0.1]",
domain: "[127.0.0.1]",
},
"medium ipv6": {
input: "[IPv6:2001:db8:aaaa:1::100]",
domain: "[IPv6:2001:db8:aaaa:1::100]",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
} else if result != tc.domain {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
}
}
})
}
}
@@ -289,6 +332,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
// Test local mailbox naming policy.
localInvalidTable := []struct {
input, msg string
@@ -303,6 +347,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
}
}
// Test full mailbox naming policy.
fullInvalidTable := []struct {
input, msg string
@@ -318,6 +363,7 @@ func TestExtractMailboxInvalid(t *testing.T) {
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
}
}
// Test domain mailbox naming policy.
domainInvalidTable := []struct {
input, msg string
@@ -365,6 +411,10 @@ func TestValidateDomain(t *testing.T) {
{strings.Repeat("a", 256), false, "Max domain length is 255"},
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
{"[0.0.0.0]", true, "Single digit octet IP addr is valid"},
{"[123.123.123.123]", true, "Multiple digit octet IP addr is valid"},
{"[IPv6:2001:0db8:aaaa:0001:0000:0000:0000:0200]", true, "Full IPv6 addr is valid"},
{"[IPv6:::1]", true, "Abbr IPv6 addr is valid"},
}
for _, tt := range testTable {
if policy.ValidateDomainPart(tt.input) != tt.expect {
@@ -419,6 +469,9 @@ func TestValidateLocal(t *testing.T) {
{"$A12345", true, "RFC3696 test case should be valid"},
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
{"_somename", true, "RFC3696 test case should be valid"},
{"@host:mailbox", true, "Forward-path routes are valid"},
{"@a.com,@b.com:mailbox", true, "Multi-hop forward-path routes are valid"},
{"@a.com,mailbox", false, "Unterminated forward-path routes are invalid"},
}
for _, tt := range testTable {
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
@@ -430,3 +483,281 @@ func TestValidateLocal(t *testing.T) {
}
}
}
// TestParseOrigin verifies Origin values returned by Addressing.ParseOrigin.
// This function parses a MAIL FROM path, not a From header. See rfc5321#section-4.1.2
func TestParseOrigin(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
tests := map[string]struct {
input string
address string
localPart string
domain string
}{
"null sender": {input: "", address: "", localPart: "", domain: ""},
"common": {input: "user@example.com", address: "user@example.com", localPart: "user", domain: "example.com"},
"with label": {input: "user+mailbox@example.com", address: "user+mailbox@example.com", localPart: "user+mailbox", domain: "example.com"},
"case preserved": {input: "User@Example.COM", address: "User@Example.COM", localPart: "User", domain: "Example.COM"},
"special chars": {input: "a!#$%&'*@example.com", address: "a!#$%&'*@example.com", localPart: "a!#$%&'*", domain: "example.com"},
"ipv4": {input: "user@[127.0.0.1]", address: "user@[127.0.0.1]", localPart: "user", domain: "[127.0.0.1]"},
"route domain": {input: "@route.com:user@example.com", address: "@route.com:user@example.com", localPart: "user", domain: "example.com"},
"quoted local part": {input: `"first last"@example.com`, address: `"first last"@example.com`, localPart: "first last", domain: "example.com"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
o, err := localPolicy.ParseOrigin(tc.input)
if err != nil {
t.Fatalf("Parse of %q failed: %v", tc.input, err)
}
if got, want := o.Address.Address, tc.address; got != want {
t.Errorf("Got Address: %q, want: %q", got, want)
}
if got, want := o.LocalPart, tc.localPart; got != want {
t.Errorf("Got LocalPart: %q, want: %q", got, want)
}
if got, want := o.Domain, tc.domain; got != want {
t.Errorf("Got Domain: %q, want: %q", got, want)
}
})
}
}
// TestParseOriginInvalid verifies Addressing.ParseOrigin rejects malformed MAIL FROM paths.
func TestParseOriginInvalid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
testCases := []struct {
input, msg string
}{
{"first last@example.com", "Space not permitted"},
{"user@bad..domain.com", "Invalid domain not permitted"},
{"user@-bad.com", "Domain cannot start with hyphen"},
{"user", "Bare local part without domain"},
{"@route.comuser@example.com", "Unterminated forward-path route"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
if o, err := localPolicy.ParseOrigin(tc.input); err == nil {
t.Errorf("Didn't get an error while parsing origin %q: %s (got %#v)", tc.input, tc.msg, o)
}
})
}
}
// TestShouldAcceptOriginDomain verifies origin-domain rejection patterns, including
// wildcards and case handling.
func TestShouldAcceptOriginDomain(t *testing.T) {
// Patterns are matched against the lowercased input domain; config.Process() is
// responsible for lowercasing the pattern list itself.
ap := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
RejectOriginDomains: []string{"*.deny.com", "deny.com", "evil?om"},
},
},
}
testCases := []struct {
domain string
want bool
}{
{domain: "foo.com", want: true},
{domain: "deny.com", want: false},
{domain: "DENY.com", want: false},
// Wildcard subdomain matches, at any depth, but not the bare domain.
{domain: "a.deny.com", want: false},
{domain: "b.a.deny.com", want: false},
{domain: "deny.com.org", want: true},
// Single-character wildcards match exactly one character.
{domain: "evilcom", want: false},
{domain: "evilaom", want: false},
{domain: "evilaacom", want: true},
}
for _, tc := range testCases {
t.Run(tc.domain, func(t *testing.T) {
got := ap.ShouldAcceptOriginDomain(tc.domain)
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Wildcard-only pattern: `*.deny.com` matches subdomains at any depth, but not the
// bare domain - operators must list that separately.
wildcardOnly := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{RejectOriginDomains: []string{"*.deny.com"}},
},
}
t.Run("wildcard only", func(t *testing.T) {
for _, tc := range []struct {
domain string
want bool
}{
{domain: "deny.com", want: true},
{domain: "a.deny.com", want: false},
{domain: "b.a.deny.com", want: false},
} {
if got := wildcardOnly.ShouldAcceptOriginDomain(tc.domain); got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
}
})
// With no RejectOriginDomains configured, every origin is accepted.
noRestrictions := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{RejectDomains: []string{"deny.com"}},
},
}
if !noRestrictions.ShouldAcceptOriginDomain("deny.com") {
t.Errorf("Got false for %q with no RejectOriginDomains configured, want: true", "deny.com")
}
// An un-lowercased pattern does not match a lowercased domain: it is up to
// config.Process() to normalize pattern lists.
upperPattern := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{RejectOriginDomains: []string{"DENY.com"}},
},
}
if !upperPattern.ShouldAcceptOriginDomain("deny.com") {
t.Errorf("Got false for %q against un-lowercased pattern, want: true", "deny.com")
}
}
// TestOriginShouldAccept drives ParseOrigin and ShouldAccept together, as the
// SMTP server does when handling a MAIL FROM argument.
func TestOriginShouldAccept(t *testing.T) {
ap := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
RejectOriginDomains: []string{"*.spam.com"},
},
},
}
tests := map[string]struct {
input string
want bool
}{
"null sender": {input: "", want: true},
"accepted": {input: "user@example.com", want: true},
"rejected": {input: "user@mail.spam.com", want: false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
o, err := ap.ParseOrigin(tc.input)
if err != nil {
t.Fatalf("Parse of %q failed: %v", tc.input, err)
}
if got := o.ShouldAccept(); got != tc.want {
t.Errorf("Got ShouldAccept %v for %q, want: %v", got, tc.input, tc.want)
}
})
}
}
// TestRecipientShouldAcceptStore drives NewRecipient with ShouldAccept and
// ShouldStore together, as the SMTP server does when handling a RCPT TO argument.
func TestRecipientShouldAcceptStore(t *testing.T) {
ap := &policy.Addressing{
Config: &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
DefaultAccept: true,
RejectDomains: []string{"reject.com"},
DefaultStore: false,
StoreDomains: []string{"keep.com"},
},
},
}
tests := map[string]struct {
input string
wantAccept bool
wantStore bool
}{
"accepted and kept": {input: "user@keep.com", wantAccept: true, wantStore: true},
"accepted, discarded": {input: "user@example.com", wantAccept: true, wantStore: false},
"rejected": {input: "user@REJECT.com", wantAccept: false, wantStore: false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
r, err := ap.NewRecipient(tc.input)
if err != nil {
t.Fatalf("Parse of %q failed: %v", tc.input, err)
}
if got := r.ShouldAccept(); got != tc.wantAccept {
t.Errorf("Got ShouldAccept %v for %q, want: %v", got, tc.input, tc.wantAccept)
}
if got := r.ShouldStore(); got != tc.wantStore {
t.Errorf("Got ShouldStore %v for %q, want: %v", got, tc.input, tc.wantStore)
}
})
}
}
// TestNewRecipientInvalid verifies error paths of Addressing.NewRecipient.
func TestNewRecipientInvalid(t *testing.T) {
testCases := []struct {
name string
policy policy.Addressing
input string
msg string
}{
{
name: "invalid address",
policy: policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}},
input: "user@bad..domain.com",
msg: "Invalid domain should fail ParseEmailAddress",
},
{
name: "unknown mailbox naming",
policy: policy.Addressing{Config: &config.Root{}},
input: "user@example.com",
msg: "Unknown MailboxNaming should fail ExtractMailbox",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if r, err := tc.policy.NewRecipient(tc.input); err == nil {
t.Errorf("Didn't get an error while parsing recipient %q: %s (got %#v)", tc.input, tc.msg, r)
}
})
}
}
// 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
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)
}
+6 -6
View File
@@ -10,10 +10,10 @@ import (
"encoding/json"
"strconv"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
@@ -26,7 +26,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
return fmt.Errorf("failed to get messages for %v: %v", name, err)
}
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
@@ -108,7 +108,7 @@ func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Contex
dec := json.NewDecoder(req.Body)
dm := model.JSONMessageHeaderV1{}
if err := dec.Decode(&dm); err != nil {
return fmt.Errorf("Failed to decode JSON: %v", err)
return fmt.Errorf("failed to decode JSON: %v", err)
}
if dm.Seen {
err = ctx.Manager.MarkSeen(name, id)
+29 -34
View File
@@ -9,30 +9,17 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/test"
"github.com/jhillyerd/enmime"
)
const (
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
headerKey = "header"
bodyKey = "body"
textKey = "text"
htmlKey = "html"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/jhillyerd/enmime/v2"
)
func TestRestMailboxList(t *testing.T) {
// Setup
mm := test.NewManager()
logbuf := setupWebServer(mm)
logbuf := setupWebServer(mm, &msghub.Hub{})
// Test invalid mailbox name
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
@@ -67,7 +54,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 +62,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 +70,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")
@@ -136,7 +123,7 @@ func TestRestMailboxList(t *testing.T) {
func TestRestMessage(t *testing.T) {
// Setup
mm := test.NewManager()
logbuf := setupWebServer(mm)
logbuf := setupWebServer(mm, &msghub.Hub{})
// Test invalid mailbox name
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
@@ -178,7 +165,7 @@ func TestRestMessage(t *testing.T) {
// Test JSON message headers
tzPST := time.FixedZone("PST", -8*3600)
msg1 := message.New(
message.Metadata{
event.MessageMetadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -200,6 +187,10 @@ func TestRestMessage(t *testing.T) {
FileName: "favicon.png",
ContentType: "image/png",
}},
Inlines: []*enmime.Part{{
FileName: "statement.pdf",
ContentType: "application/pdf",
}},
},
)
mm.AddMessage("good", msg1)
@@ -235,10 +226,14 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
decodedStringEquals(t, result, "attachments/[0]/filename", "statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/content-type", "application/pdf")
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[1]/filename", "favicon.png")
decodedStringEquals(t, result, "attachments/[1]/content-type", "image/png")
decodedStringEquals(t, result, "attachments/[1]/download-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
decodedStringEquals(t, result, "attachments/[1]/view-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
if t.Failed() {
// Wait for handler to finish logging
@@ -250,11 +245,11 @@ func TestRestMessage(t *testing.T) {
func TestRestMarkSeen(t *testing.T) {
mm := test.NewManager()
logbuf := setupWebServer(mm)
logbuf := setupWebServer(mm, &msghub.Hub{})
// Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{
meta1 := event.MessageMetadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -262,7 +257,7 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := message.Metadata{
meta2 := event.MessageMetadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
@@ -270,8 +265,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
+97 -22
View File
@@ -3,12 +3,12 @@ package client
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1
@@ -18,15 +18,22 @@ type Client struct {
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
// "http://localhost:9000"
func New(baseURL string) (*Client, error) {
func New(baseURL string, opts ...Option) (*Client, error) {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
mergedOpts := getDefaultOptions()
for _, opt := range opts {
opt.apply(mergedOpts)
}
c := &Client{
restClient{
client: &http.Client{
Timeout: 30 * time.Second,
Timeout: mergedOpts.timeout,
Transport: mergedOpts.transport,
},
baseURL: parsedURL,
},
@@ -35,33 +42,56 @@ func New(baseURL string) (*Client, error) {
}
// ListMailbox returns a list of messages for the requested mailbox
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
func (c *Client) ListMailbox(name string) ([]*MessageHeader, error) {
return c.ListMailboxWithContext(context.Background(), name)
}
// ListMailboxWithContext returns a list of messages for the requested mailbox
func (c *Client) ListMailboxWithContext(ctx context.Context, name string) ([]*MessageHeader, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
err = c.doJSON("GET", uri, &headers)
headers := make([]*MessageHeader, 0, 32)
err := c.doJSON(ctx, "GET", uri, &headers)
if err != nil {
return nil, err
}
// Add Client ref to each MessageHeader for convenience funcs.
for _, h := range headers {
h.client = c
}
return
return headers, nil
}
// GetMessage returns the message details given a mailbox name and message ID.
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
return c.GetMessageWithContext(context.Background(), name, id)
}
// GetMessageWithContext returns the message details given a mailbox name and message ID.
func (c *Client) GetMessageWithContext(ctx context.Context, name, id string) (*Message, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err = c.doJSON("GET", uri, &message)
var message Message
err := c.doJSON(ctx, "GET", uri, &message)
if err != nil {
return nil, err
}
message.client = c
return
return &message, nil
}
// MarkSeen marks the specified message as having been read.
func (c *Client) MarkSeen(name, id string) error {
return c.MarkSeenWithContext(context.Background(), name, id)
}
// MarkSeenWithContext marks the specified message as having been read.
func (c *Client) MarkSeenWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err := c.doJSON("PATCH", uri, nil)
err := c.doJSON(ctx, "PATCH", uri, nil)
if err != nil {
return err
}
@@ -70,19 +100,25 @@ func (c *Client) MarkSeen(name, id string) error {
// GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
return c.GetMessageSourceWithContext(context.Background(), name, id)
}
// GetMessageSourceWithContext returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSourceWithContext(ctx context.Context, name, id string) (*bytes.Buffer, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
resp, err := c.do("GET", uri, nil)
resp, err := c.do(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil,
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
return buf, err
@@ -90,29 +126,43 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
return c.DeleteMessageWithContext(context.Background(), name, id)
}
// DeleteMessageWithContext deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessageWithContext(ctx context.Context, name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error {
return c.PurgeMailboxWithContext(context.Background(), name)
}
// PurgeMailboxWithContext deletes all messages in the given mailbox
func (c *Client) PurgeMailboxWithContext(ctx context.Context, name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri, nil)
resp, err := c.do(ctx, "DELETE", uri, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
@@ -124,17 +174,32 @@ type MessageHeader struct {
// GetMessage returns this message with content
func (h *MessageHeader) GetMessage() (message *Message, err error) {
return h.client.GetMessage(h.Mailbox, h.ID)
return h.GetMessageWithContext(context.Background())
}
// GetMessageWithContext returns this message with content
func (h *MessageHeader) GetMessageWithContext(ctx context.Context) (message *Message, err error) {
return h.client.GetMessageWithContext(ctx, h.Mailbox, h.ID)
}
// GetSource returns the source for this message
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
return h.client.GetMessageSource(h.Mailbox, h.ID)
return h.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (h *MessageHeader) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return h.client.GetMessageSourceWithContext(ctx, h.Mailbox, h.ID)
}
// Delete deletes this message from the mailbox
func (h *MessageHeader) Delete() error {
return h.client.DeleteMessage(h.Mailbox, h.ID)
return h.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (h *MessageHeader) DeleteWithContext(ctx context.Context) error {
return h.client.DeleteMessageWithContext(ctx, h.Mailbox, h.ID)
}
// Message represents an Inbucket message including content
@@ -145,10 +210,20 @@ type Message struct {
// GetSource returns the source for this message
func (m *Message) GetSource() (*bytes.Buffer, error) {
return m.client.GetMessageSource(m.Mailbox, m.ID)
return m.GetSourceWithContext(context.Background())
}
// GetSourceWithContext returns the source for this message
func (m *Message) GetSourceWithContext(ctx context.Context) (*bytes.Buffer, error) {
return m.client.GetMessageSourceWithContext(ctx, m.Mailbox, m.ID)
}
// Delete deletes this message from the mailbox
func (m *Message) Delete() error {
return m.client.DeleteMessage(m.Mailbox, m.ID)
return m.DeleteWithContext(context.Background())
}
// DeleteWithContext deletes this message from the mailbox
func (m *Message) DeleteWithContext(ctx context.Context) error {
return m.client.DeleteMessageWithContext(ctx, m.Mailbox, m.ID)
}
+39
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}
}
+48 -5
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/inbucket/inbucket/pkg/rest/client"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
func TestClientV1ListMailbox(t *testing.T) {
@@ -209,6 +212,33 @@ func TestClientV1GetMessageSource(t *testing.T) {
}
}
func TestClientV1WithCustomTransport(t *testing.T) {
// Call setup, passing a custom roundtripper and make sure it was used during the request.
mockRoundTripper := &mockRoundTripper{ResponseBody: "Custom Transport"}
c, router, teardown := setup(client.WithTransport(mockRoundTripper))
defer teardown()
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
Handler(&jsonHandler{json: `message source`})
// Method under test.
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want := mockRoundTripper.ResponseBody
got := source.String()
if got != want {
t.Errorf("Source got %q, want %q", got, want)
}
if mockRoundTripper.CallCount != 1 {
t.Errorf("RoundTripper called %v times, want 1", mockRoundTripper.CallCount)
}
}
func TestClientV1DeleteMessage(t *testing.T) {
// Setup.
c, router, teardown := setup()
@@ -336,11 +366,24 @@ func TestClientV1MessageHeader(t *testing.T) {
}
}
type mockRoundTripper struct {
ResponseBody string
CallCount int
}
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
m.CallCount++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(m.ResponseBody)),
}, nil
}
// setup returns a client, router and server for API testing.
func setup() (c *client.Client, router *mux.Router, teardown func()) {
func setup(opts ...client.Option) (c *client.Client, router *mux.Router, teardown func()) {
router = mux.NewRouter()
server := httptest.NewServer(router)
c, err := client.New(server.URL)
c, err := client.New(server.URL, opts...)
if err != nil {
panic(err)
}
@@ -357,5 +400,5 @@ type jsonHandler struct {
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
j.called = true
w.Write([]byte(j.json))
_, _ = w.Write([]byte(j.json))
}
+35 -27
View File
@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
)
// Example demonstrates basic usage for the Inbucket REST client.
@@ -16,34 +16,42 @@ func Example() {
baseURL, teardown := exampleSetup()
defer teardown()
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
log.Fatal(err)
}
err := func() error {
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
return err
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
log.Fatal(err)
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
return err
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
log.Fatal(err)
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
return err
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Delete the second message.
err = headers[1].Delete()
if err != nil {
return err
}
return nil
}()
// Delete the second message.
err = headers[1].Delete()
if err != nil {
log.Fatal(err)
log.Print(err)
}
// Output:
@@ -62,7 +70,7 @@ func exampleSetup() (baseURL string, teardown func()) {
// Handle ListMailbox request.
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[
_, _ = w.Write([]byte(`[
{
"mailbox": "user1",
"id": "20180107T224128-0000",
@@ -79,7 +87,7 @@ func exampleSetup() (baseURL string, teardown func()) {
// Handle GetMessage request.
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{
_, _ = w.Write([]byte(`{
"mailbox": "user1",
"id": "20180107T224128-0000",
"from": "admin@inbucket.org",
+9 -31
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,47 +22,24 @@ type restClient struct {
}
// do performs an HTTP request with this client and returns the response.
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
func (c *restClient) do(ctx context.Context, method, uri string, body []byte) (*http.Response, error) {
url := c.baseURL.JoinPath(uri)
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url.String(), r)
req, err := http.NewRequestWithContext(ctx, method, url.String(), r)
if err != nil {
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri, nil)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
resp, err := c.do(method, uri, body)
func (c *restClient) doJSON(ctx context.Context, method string, uri string, v interface{}) error {
resp, err := c.do(ctx, method, uri, nil)
if err != nil {
return err
}
+49 -28
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)
}
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"time"
)
// JSONMessageHeaderV1 contains the basic header data for a message
// JSONMessageHeaderV1 contains the basic header data for a message.
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
type JSONMessageV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
// JSONMessageAttachmentV1 contains information about a MIME attachment
// JSONMessageAttachmentV1 contains information about a MIME attachment.
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`
@@ -42,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
MD5 string `json:"md5"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
// JSONMessageBodyV1 contains the Text and HTML versions of the message body.
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`
+15
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"`
}
+10 -2
View File
@@ -1,7 +1,9 @@
package rest
import "github.com/gorilla/mux"
import "github.com/inbucket/inbucket/pkg/server/web"
import (
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/server/web"
)
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
@@ -22,4 +24,10 @@ func SetupRoutes(r *mux.Router) {
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
// API v2
r.Path("/v2/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV2)).Name("MonitorAllMessagesV2").Methods("GET")
r.Path("/v2/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV2)).Name("MonitorMailboxMessagesV2").Methods("GET")
}
+254
View File
@@ -0,0 +1,254 @@
package rest
import (
"errors"
"net/http"
"sync"
"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/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
const (
// socketWriteWait is the time allowed to write a message to the peer.
socketWriteWait = 10 * time.Second
// socketPingPeriod is how often pings are sent to the peer. Must be less than
// socketPongWait.
socketPingPeriod = (socketPongWait * 9) / 10
// socketPongWait is the time allowed to read the next pong message from the peer.
socketPongWait = 60 * time.Second
// socketMaxMessageSize is the maximum message size allowed from the peer.
socketMaxMessageSize = 512
// socketChanLen is the event queue length of a monitor listener.
socketChanLen = 100
)
// upgrader holds the options for monitor WebSocket connection upgrades.
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListener handles messages from the msghub for a single monitor connection. Stored and
// deleted events are shaped into the API-specific payload by toStored/toDeleted; a nil
// toDeleted drops deletions (socketv1 API behavior).
type msgListener[T any] struct {
hub *msghub.Hub
c chan T
done chan struct{} // Closed by Close; stops event delivery and the write pump
mailbox string // Name of mailbox to monitor, "" == all mailboxes
toStored func(msg event.MessageMetadata) T
toDeleted func(mailbox string, id string) T
closeOnce sync.Once
}
// newMsgListener creates a listener and registers it with the hub. The optional mailbox
// parameter restricts events relayed to the client to that mailbox only.
func newMsgListener[T any](
hub *msghub.Hub,
mailbox string,
toStored func(msg event.MessageMetadata) T,
toDeleted func(mailbox string, id string) T,
) *msgListener[T] {
ml := &msgListener[T]{
hub: hub,
c: make(chan T, socketChanLen),
done: make(chan struct{}),
mailbox: mailbox,
toStored: toStored,
toDeleted: toDeleted,
}
hub.AddListener(ml)
return ml
}
// watches reports whether events for the given mailbox should be relayed to the client.
func (ml *msgListener[T]) watches(mailbox string) bool {
return ml.mailbox == "" || ml.mailbox == mailbox
}
// errListenerClosed is returned by Receive/Delete after Close; the hub removes listeners
// that return errors, providing deregistration even if RemoveListener has not run yet.
var errListenerClosed = errors.New("monitor listener closed")
// Receive handles an incoming message. If the listener is closed while its event queue is
// full, the enqueue is abandoned rather than blocking the hub actor forever.
func (ml *msgListener[T]) Receive(msg event.MessageMetadata) error {
if !ml.watches(msg.Mailbox) {
return nil
}
select {
case ml.c <- ml.toStored(msg):
return nil
case <-ml.done:
return errListenerClosed
}
}
// Delete handles a deleted message.
func (ml *msgListener[T]) Delete(mailbox string, id string) error {
if ml.toDeleted == nil || !ml.watches(mailbox) {
return nil
}
select {
case ml.c <- ml.toDeleted(mailbox, id):
return nil
case <-ml.done:
return errListenerClosed
}
}
// Close removes the listener registration and stops event delivery. Safe for concurrent
// use: the read and write pumps both invoke it when the connection drops. The done channel
// is closed before the removal is queued: a hub actor blocked in this listener's Receive or
// Delete is released first, so it keeps draining the hub op queue and the removal can be
// queued even when that queue is full. The event queue is not closed: RemoveListener
// returns once the op is queued, not processed, so a concurrent hub-actor send into the
// queue could otherwise panic on the close; the done channel ends delivery and unblocks
// any such send.
func (ml *msgListener[T]) Close() {
ml.closeOnce.Do(func() {
// Release a hub actor blocked in Receive or Delete before queueing the removal:
// with the removal queued first, a full hub op queue would block it here forever,
// never reaching the close below -- the only operation able to release the actor.
close(ml.done)
ml.hub.RemoveListener(ml)
})
}
// socketReadPump makes sure the websocket client is still connected, discarding any messages
// the client sends. It returns once the connection fails or the peer closes it; closeFn is
// then invoked to tear the listener down.
func socketReadPump(conn *websocket.Conn, closeFn func()) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer closeFn()
conn.SetReadLimit(socketMaxMessageSize)
if err := conn.SetReadDeadline(time.Now().Add(socketPongWait)); 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(socketPongWait)); 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
}
}
}
// socketWritePump relays events from c to the peer as JSON messages until done is closed,
// sending pings while idle to keep the connection alive. closeFn is invoked on exit.
func socketWritePump[T any](c chan T, done <-chan struct{}, conn *websocket.Conn, closeFn func()) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
ticker := time.NewTicker(socketPingPeriod)
defer func() {
ticker.Stop()
closeFn()
}()
// Handle messages from hub until listener is closed
for {
select {
case event, ok := <-c:
if err := conn.SetWriteDeadline(time.Now().Add(socketWriteWait)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
}
if !ok {
// Listener closed, exit
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if conn.WriteJSON(event) != nil {
// Write failed
return
}
case <-done:
// Listener closed, exit
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
case <-ticker.C:
// Send ping
if err := conn.SetWriteDeadline(time.Now().Add(socketWriteWait)); 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")
}
}
}
// serveMonitor upgrades the connection to a WebSocket, registers a new listener with the hub,
// and relays events to the client until it disconnects.
func serveMonitor[T any](
w http.ResponseWriter,
req *http.Request,
hub *msghub.Hub,
mailbox string,
newListener func(hub *msghub.Hub, mailbox string) *msgListener[T],
) error {
// Upgrade to Websocket.
conn, err := upgrader.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 := newListener(hub, mailbox)
go socketWritePump(ml.c, ml.done, conn, ml.Close)
socketReadPump(conn, ml.Close)
return nil
}
// metadataToHeader converts hub event metadata into a JSON message header.
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,
}
}
+678
View File
@@ -0,0 +1,678 @@
package rest
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"net/mail"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// monitorTestTimeout bounds WebSocket reads so a broken server fails the test instead of hanging.
const monitorTestTimeout = 5 * time.Second
// monitorDialer dials test servers directly; the default dialer would honor proxy env vars.
var monitorDialer = &websocket.Dialer{}
// startMsgHub returns a running *msghub.Hub with room for every message a test dispatches
// (replayed to listeners that connect later). The actor goroutine is stopped via t.Cleanup.
func startMsgHub(t *testing.T) *msghub.Hub {
t.Helper()
hub := msghub.New(10, extension.NewHost())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go hub.Start(ctx)
return hub
}
// setupMonitorServer wires a real (running) msg hub into the web server globals and serves the
// global router over HTTP; upgrading WebSocket endpoints needs a real connection to hijack.
// The returned gauge baseline is the value of web.ExpWebSocketConnectsCurrent before any
// connection is made; pass it to dialMonitor so sessions can be awaited on teardown.
func setupMonitorServer(t *testing.T, hub *msghub.Hub) (*httptest.Server, int64) {
t.Helper()
mm := test.NewManager()
setupWebServer(mm, hub)
server := httptest.NewServer(web.Router)
// Registered before any connection cleanup so the listener socket is closed after the
// sessions below have been awaited.
t.Cleanup(server.Close)
return server, web.ExpWebSocketConnectsCurrent.Value()
}
// dialMonitor connects a WebSocket client to path on server. The cleanup closes the connection
// and waits for the server-side handler goroutine to exit, which is required before the msg hub
// may be stopped: httptest.Server.Close does not wait for hijacked (WebSocket) connections, and
// a handler deregistering its hub listener after the hub was stopped races with it.
func dialMonitor(t *testing.T, server *httptest.Server, path string, gaugeBase int64) *websocket.Conn {
t.Helper()
url := "ws" + strings.TrimPrefix(server.URL, "http") + path
conn, resp, err := monitorDialer.Dial(url, nil)
require.NoError(t, err)
// resp.Body is an empty no-op closer once the connection is upgraded.
_ = resp.Body.Close()
t.Cleanup(func() {
_ = conn.Close()
require.Eventually(t, func() bool {
return web.ExpWebSocketConnectsCurrent.Value() == gaugeBase
}, monitorTestTimeout, time.Millisecond, "server handler did not shut down")
})
return conn
}
// readMonitorJSON reads the next data message from conn and decodes it into a generic map.
func readMonitorJSON(t *testing.T, conn *websocket.Conn) map[string]interface{} {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(monitorTestTimeout)))
_, data, err := conn.ReadMessage()
require.NoError(t, err)
var decoded interface{}
require.NoError(t, json.Unmarshal(data, &decoded), "payload: %s", data)
obj, ok := decoded.(map[string]interface{})
require.True(t, ok, "expected JSON object, got: %s", data)
return obj
}
// testMetadata builds a message for dispatch into the hub.
func testMetadata(mailbox, id string) event.MessageMetadata {
return event.MessageMetadata{
Mailbox: mailbox,
ID: id,
From: &mail.Address{Address: "from-" + id + "@host"},
To: []*mail.Address{{Address: "to-" + id + "@host"}},
Subject: "subject " + id,
Date: time.Date(2012, 2, 1, 10, 11, 12, 0, time.UTC),
Size: 1234,
}
}
// assertMonitorHeader checks the JSONMessageHeaderV1 fields of a decoded monitor payload.
func assertMonitorHeader(t *testing.T, decoded interface{}, want event.MessageMetadata) {
t.Helper()
decodedStringEquals(t, decoded, "mailbox", want.Mailbox)
decodedStringEquals(t, decoded, "id", want.ID)
decodedStringEquals(t, decoded, "from", "<"+want.From.Address+">")
decodedStringEquals(t, decoded, "to/[0]", "<"+want.To[0].Address+">")
decodedStringEquals(t, decoded, "subject", want.Subject)
decodedStringEquals(t, decoded, "date", "2012-02-01T10:11:12Z")
decodedNumberEquals(t, decoded, "posix-millis", float64(want.Date.UnixNano()/1000000))
decodedNumberEquals(t, decoded, "size", float64(want.Size))
}
// monitorEventHeader returns the nested header object of a decoded v2 monitor event.
func monitorEventHeader(t *testing.T, ev map[string]interface{}) map[string]interface{} {
t.Helper()
header, ok := ev["header"].(map[string]interface{})
require.True(t, ok, "event has no header object: %v", ev)
return header
}
func TestMonitorAllMessagesV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// Messages dispatched before connecting are replayed from hub history to new listeners.
replayed := testMetadata("replay", "0001")
hub.Dispatch(replayed)
conn := dialMonitor(t, server, "/api/v1/monitor/messages", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), replayed)
assert.Equal(t, gaugeBase+1, web.ExpWebSocketConnectsCurrent.Value(),
"open WebSocket connections gauge")
// Messages dispatched after registration are delivered live. Receiving the replayed
// message above proves the listener was registered before this dispatch.
live := testMetadata("live", "0002")
hub.Dispatch(live)
assertMonitorHeader(t, readMonitorJSON(t, conn), live)
// A clean client close tears the session down server-side: the read pump sees the close
// frame, the listener deregisters from the hub, and the connections gauge drops back.
require.NoError(t, conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(monitorTestTimeout)))
require.NoError(t, conn.Close())
require.Eventually(t, func() bool {
return web.ExpWebSocketConnectsCurrent.Value() == gaugeBase
}, monitorTestTimeout, time.Millisecond, "open WebSocket connections gauge")
}
func TestMonitorMailboxMessagesV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
hub.Dispatch(testMetadata("other", "0001"))
watched := testMetadata("watched", "0002")
hub.Dispatch(watched)
hub.Dispatch(testMetadata("other", "0003"))
conn := dialMonitor(t, server, "/api/v1/monitor/messages/watched", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), watched)
// Events are relayed in dispatch order, so receiving 0005 next proves both the replayed
// and live deliveries for other mailboxes were filtered, not merely delayed.
hub.Dispatch(testMetadata("other", "0004"))
live := testMetadata("watched", "0005")
hub.Dispatch(live)
assertMonitorHeader(t, readMonitorJSON(t, conn), live)
// Documents current behavior: v1 monitors ignore message deletions and emit no event;
// v2 emits message-deleted. The subsequent dispatch arriving next proves the delete
// produced nothing. Flagged for a follow-up fix.
hub.Delete("watched", "0005")
after := testMetadata("watched", "0006")
hub.Dispatch(after)
assertMonitorHeader(t, readMonitorJSON(t, conn), after)
}
func TestMonitorMailboxAddressV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// The {name} path segment goes through Manager.MailboxForAddress; with the test stub's
// full-address naming policy, user@example.com maps to a mailbox of the same name.
stored := testMetadata("user@example.com", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v1/monitor/messages/user%40example.com", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), stored)
}
func TestMonitorMailboxInvalidNameV1(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// Invalid mailbox names are rejected before the connection is upgraded.
w, err := testRestGet("http://localhost/api/v1/monitor/messages/foo%20bar")
require.NoError(t, err)
assert.Equal(t, 500, w.Code)
}
func TestMonitorUpgradeFailedV1(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// A plain GET without WebSocket upgrade headers fails the handshake; the upgrader writes
// the 400 itself before the handler error is reported.
w, err := testRestGet("http://localhost/api/v1/monitor/messages")
require.NoError(t, err)
assert.Equal(t, 400, w.Code)
}
func TestMonitorClientCloseV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
stored := testMetadata("close", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v1/monitor/messages", gaugeBase)
// Data messages sent by the client are discarded; the monitor keeps relaying events.
require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"junk":true}`)))
assertMonitorHeader(t, readMonitorJSON(t, conn), stored)
// A clean client close ends the session. The peer may observe any of: the close-code
// echo sent by the server read pump's default close handler, the writer's empty close
// frame, or a dropped TCP connection from the handler teardown; all indicate shutdown.
require.NoError(t, conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(monitorTestTimeout)))
require.NoError(t, conn.SetReadDeadline(time.Now().Add(monitorTestTimeout)))
_, _, err := conn.ReadMessage()
require.Error(t, err)
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
assert.Contains(t, []int{
websocket.CloseNormalClosure, // echoed by the server read pump
websocket.CloseNoStatusReceived, // writer's empty close frame
}, closeErr.Code)
}
}
func TestMonitorAllMessagesV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// Stored messages dispatched before connecting are replayed from hub history.
stored := testMetadata("replay", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v2/monitor/messages", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), stored)
// Data messages sent by the client are discarded; the monitor keeps relaying events.
require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"junk":true}`)))
live := testMetadata("live", "0002")
hub.Dispatch(live)
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), live)
// Deletions are reported as message-deleted events with the message identifier.
hub.Delete("live", "0002")
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-deleted")
decodedStringEquals(t, ev, "identifier/mailbox", "live")
decodedStringEquals(t, ev, "identifier/id", "0002")
}
func TestMonitorMailboxMessagesV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
hub.Dispatch(testMetadata("other", "0001"))
watched := testMetadata("watched", "0002")
hub.Dispatch(watched)
conn := dialMonitor(t, server, "/api/v2/monitor/messages/watched", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), watched)
// Events are relayed in dispatch order, so receiving message-stored for 0003 next proves
// the other-mailbox delete was filtered, not merely delayed.
hub.Delete("other", "0001")
live := testMetadata("watched", "0003")
hub.Dispatch(live)
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), live)
hub.Delete("other", "9999")
hub.Delete("watched", "0003")
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-deleted")
decodedStringEquals(t, ev, "identifier/mailbox", "watched")
decodedStringEquals(t, ev, "identifier/id", "0003")
}
func TestMonitorMailboxAddressV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// The {name} path segment goes through Manager.MailboxForAddress; with the test stub's
// full-address naming policy, user@example.com maps to a mailbox of the same name.
stored := testMetadata("user@example.com", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v2/monitor/messages/user%40example.com", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), stored)
}
func TestMonitorMailboxInvalidNameV2(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// Invalid mailbox names are rejected before the connection is upgraded.
w, err := testRestGet("http://localhost/api/v2/monitor/messages/foo%20bar")
require.NoError(t, err)
assert.Equal(t, 500, w.Code)
}
func TestMonitorUpgradeFailedV2(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// A plain GET without WebSocket upgrade headers fails the handshake; the upgrader writes
// the 400 itself before the handler error is reported.
w, err := testRestGet("http://localhost/api/v2/monitor/messages")
require.NoError(t, err)
assert.Equal(t, 400, w.Code)
}
// assertNoMoreEvents fails the test if any further events are queued on the channel.
func assertNoMoreEvents[T any](t *testing.T, c chan T) {
t.Helper()
select {
case ev := <-c:
t.Fatalf("unexpected event: %v", ev)
default:
}
}
func TestMsgListenerWatches(t *testing.T) {
testCases := []struct {
name string
mailbox string // listener watch; "" watches all mailboxes
event string // mailbox of the incoming event
want bool
}{
{name: "all watcher matches any mailbox", mailbox: "", event: "anything", want: true},
{name: "watched mailbox matches", mailbox: "watched", event: "watched", want: true},
{name: "other mailbox filtered", mailbox: "watched", event: "other", want: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ml := &msgListener[string]{mailbox: tc.mailbox}
assert.Equal(t, tc.want, ml.watches(tc.event))
})
}
}
func TestMsgListenerReceiveFiltersAndShapes(t *testing.T) {
hub := startMsgHub(t)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.Mailbox + "/" + msg.ID },
func(mailbox, id string) string { return "deleted:" + mailbox + "/" + id })
require.NoError(t, ml.Receive(testMetadata("other", "0001")))
require.NoError(t, ml.Receive(testMetadata("watched", "0002")))
require.NoError(t, ml.Delete("other", "0003"))
require.NoError(t, ml.Delete("watched", "0004"))
// Events for other mailboxes are filtered; payloads are shaped by the transforms.
assert.Equal(t, "stored:watched/0002", <-ml.c)
assert.Equal(t, "deleted:watched/0004", <-ml.c)
assertNoMoreEvents(t, ml.c)
}
func TestMsgListenerNilDeletedDropsDeletes(t *testing.T) {
hub := startMsgHub(t)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.ID },
nil) // socketv1 API ignores deletions
require.NoError(t, ml.Receive(testMetadata("watched", "0001")))
require.NoError(t, ml.Delete("watched", "0001"))
assert.Equal(t, "stored:0001", <-ml.c)
assertNoMoreEvents(t, ml.c)
}
func TestMsgListenerCloseIdempotent(t *testing.T) {
hub := startMsgHub(t)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored" },
func(mailbox, id string) string { return "deleted" })
ml.Close()
ml.Close() // second close must not panic or double-close the done channel
// Close is exactly-once: the done channel ends delivery and the write pump.
select {
case <-ml.done:
default:
t.Fatal("done channel should be closed after Close")
}
// Closed listeners either deliver into the never-again-read queue or report closed;
// both outcomes are terminal, as the hub-actor send races the done channel.
err := ml.Receive(testMetadata("watched", "0001"))
if err != nil {
require.ErrorIs(t, err, errListenerClosed)
}
err = ml.Delete("watched", "0001")
if err != nil {
require.ErrorIs(t, err, errListenerClosed)
}
}
func TestMsgListenerCloseFullQueue(t *testing.T) {
// A disconnecting client may leave a full event queue behind; Close must still
// deregister the listener, or the hub actor blocks on its next Receive into the full
// queue forever, wedging all monitor dispatch. Regression: the old check-then-act
// select mistook the buffered events for a closed-channel signal and skipped
// RemoveListener entirely.
hub := startMsgHub(t)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.ID }, nil)
// Fill the event queue (capacity 100) with no reader draining it.
for i := range 100 {
hub.Dispatch(testMetadata("watched", strconv.Itoa(i)))
}
hub.Sync()
ml.Close()
hub.Sync()
// The backlog remains queued, but the listener is deregistered: dispatch must not
// block the hub, and must not be delivered. Pre-fix, the no-op Close left the
// listener registered, so the dispatch was delivered (and the next dispatch would
// have wedged the actor inside Receive).
backlog, _ := drainEvents(ml.c)
assert.Equal(t, 100, backlog, "backlog should remain queued after Close")
hub.Dispatch(testMetadata("watched", "after-close"))
hub.Sync()
count, _ := drainEvents(ml.c)
assert.Equal(t, 0, count, "closed listener must not receive further events")
}
// drainEvents reads buffered events from c until it is empty; it reports how many events
// were drained and whether the channel was found closed. A receive from a closed channel
// succeeds immediately, so a plain select-default loop would never terminate on one.
func drainEvents[T any](c chan T) (count int, closed bool) {
for {
select {
case _, ok := <-c:
if !ok {
return count, true
}
count++
default:
return count, false
}
}
}
// blockingListener stalls the hub actor inside Receive until released.
type blockingListener struct {
release chan struct{}
}
func (l *blockingListener) Receive(msg event.MessageMetadata) error {
<-l.release
return nil
}
func (l *blockingListener) Delete(mailbox string, id string) error {
return nil
}
func TestMsgListenerCloseConcurrent(t *testing.T) {
// The read and write pumps both call Close when the connection drops, so Close must be
// safe to call concurrently. Regression: the check-then-act select let both callers
// through the default branch and panicked with "close of closed channel" on the second
// close.
//
// Deterministic reproduction: a blocking listener stalls the hub actor mid-dispatch,
// and enough further dispatches are queued to keep the hub op queue (capacity 100)
// full. Every concurrent Close then blocks inside RemoveListener -- already past its
// check of the listener state -- before any of them can proceed. Releasing the actor
// lets it process the pending RemoveListeners, resuming all the blocked closers at
// once.
hub := startMsgHub(t)
blocker := &blockingListener{release: make(chan struct{})}
hub.AddListener(blocker)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.ID },
func(mailbox, id string) string { return "deleted:" + id })
const queued = 101 // 1 dispatch stalls the actor; 100 fill the hub op queue
for i := range queued {
hub.Dispatch(testMetadata("watched", strconv.Itoa(i)))
}
// The stalled dispatch may have delivered 0 or 1 events to ml (listener iteration
// order is random); drain whatever arrived so the queue is empty when the closers
// check it.
drainEvents(ml.c)
const callers = 8
done := make(chan struct{}, callers) // ensure capacity so we do not block population
for range callers {
go func() {
ml.Close()
done <- struct{}{}
}()
}
// Give every closer time to block inside RemoveListener before the actor resumes;
// otherwise their ops interleave with the queued dispatches and the reproduction
// becomes timing-dependent.
time.Sleep(50 * time.Millisecond)
close(blocker.release)
for range callers {
<-done
}
// Events racing in before the hub processed the RemoveListeners may sit in the queue,
// but once it has (Sync), the listener must be deregistered: later dispatches are
// neither delivered to it nor blocking the hub.
hub.Sync()
drainEvents(ml.c)
hub.Dispatch(testMetadata("watched", "after-close"))
hub.Sync()
count, _ := drainEvents(ml.c)
assert.Equal(t, 0, count, "closed listener must not receive further events")
}
func TestMsgListenerCloseUnblocksHub(t *testing.T) {
// Close must unblock a hub actor stuck sending into the listener's full event queue:
// a slow monitor client disconnecting with a backlog of events would otherwise wedge
// every hub dispatch. (With the original close(ml.c) approach, this
// send-on-closed-channel panicked inside the actor instead.)
hub := startMsgHub(t)
blocker := &blockingListener{release: make(chan struct{})}
hub.AddListener(blocker)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.ID }, nil)
// Stall the actor, then queue enough dispatches to fill both the hub op queue and the
// listener's event queue once deliveries resume.
const queued = 101 // 1 dispatch stalls the actor; 100 fill the hub op queue
for i := range queued {
hub.Dispatch(testMetadata("watched", strconv.Itoa(i)))
}
drainEvents(ml.c) // drop the 0 or 1 events delivered before the actor stalled
close(blocker.release)
// Once the queued dispatches fill the event queue, the next dispatch blocks the actor
// inside Receive; run it from its own goroutine so the test can proceed to Close.
dispatched := make(chan struct{}, 1)
go func() {
hub.Dispatch(testMetadata("watched", "one-too-many"))
dispatched <- struct{}{}
}()
ml.Close()
// The blocked send must abort and the hub must keep processing.
synced := make(chan struct{}, 1)
go func() {
hub.Sync()
synced <- struct{}{}
}()
select {
case <-synced:
case <-time.After(5 * time.Second):
t.Fatal("hub actor still wedged after Close")
}
select {
case <-dispatched:
case <-time.After(5 * time.Second):
t.Fatal("dispatch still blocked after Close")
}
}
func TestMsgListenerCloseFullHubOpQueue(t *testing.T) {
// Regression (PR #623 review): with the hub actor blocked inside this listener's
// Receive (full event queue) and the hub op queue (capacity 100) also full,
// RemoveListener cannot enqueue. Close must therefore close done before queueing the
// removal: with the removal queued first, execution never reached close(ml.done) -- the
// only operation able to release the actor -- and Close deadlocked under the same
// slow-client condition while concurrent dispatchers kept the hub queue saturated.
hub := startMsgHub(t)
blocker := &blockingListener{release: make(chan struct{})}
hub.AddListener(blocker)
ml := newMsgListener(hub, "watched",
func(msg event.MessageMetadata) string { return "stored:" + msg.ID }, nil)
// Stall the actor inside the blocker, then fill the hub op queue behind it: the first
// dispatch wedges the actor, the next 100 fill the queue. The stalled dispatch may
// have delivered 0 or 1 events to ml (listener iteration order is random); drain
// whatever arrived so the counts below are stable.
for i := range 101 {
hub.Dispatch(testMetadata("watched", strconv.Itoa(i)))
}
drainEvents(ml.c)
// Once the blocker is released, a dispatcher goroutine keeps the op queue saturated:
// every slot the actor frees is refilled, so by the time the event queue (capacity 100)
// fills and the actor wedges inside Receive, the op queue is full again and the
// dispatcher is blocked on its next enqueue. 150 refills exceed the ~102 slots freed
// until the wedge, leaving spare dispatches to top the queue up.
dispatched := make(chan struct{})
go func() {
defer close(dispatched)
for i := range 150 {
hub.Dispatch(testMetadata("watched", strconv.Itoa(100+i)))
}
}()
close(blocker.release)
// Wait for the wedge: the event queue is full and nothing drains it. The sleep lets
// the dispatcher refill the one slot the wedging op vacated and block itself.
require.Eventually(t, func() bool {
return len(ml.c) == socketChanLen
}, monitorTestTimeout, time.Millisecond, "event queue should fill and wedge the hub actor")
time.Sleep(50 * time.Millisecond)
// Close must return despite the wedged actor and the saturated op queue.
closed := make(chan struct{})
go func() {
ml.Close()
close(closed)
}()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("Close deadlocked: hub actor blocked in Receive with a full op queue")
}
// The released actor must drain the saturated op queue: the dispatcher completes and
// the hub answers Sync again.
select {
case <-dispatched:
case <-time.After(5 * time.Second):
t.Fatal("dispatches still blocked after Close")
}
synced := make(chan struct{}, 1)
go func() {
hub.Sync()
synced <- struct{}{}
}()
select {
case <-synced:
case <-time.After(5 * time.Second):
t.Fatal("hub actor still wedged after Close")
}
// The listener is deregistered: later dispatches are not delivered to it.
drainEvents(ml.c)
hub.Dispatch(testMetadata("watched", "after-close"))
hub.Sync()
count, _ := drainEvents(ml.c)
assert.Equal(t, 0, count, "closed listener must not receive further events")
}
+16 -176
View File
@@ -2,197 +2,37 @@ package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/rs/zerolog/log"
"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"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 512
)
// options for gorilla connection upgrader
var upgrader = 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
}
// newMsgListener 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{
hub: hub,
c: make(chan msghub.Message, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message
func (ml *msgListener) Receive(msg msghub.Message) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match mailbox name
return nil
}
ml.c <- msg
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListener) 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.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
conn.SetReadDeadline(time.Now().Add(pongWait))
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 *msgListener) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case msg, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
conn.SetWriteDeadline(time.Now().Add(writeWait))
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListener) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
// newMsgListenerV1 creates a v1 API listener which relays message headers only; deletions are
// ignored by the socketv1 API.
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListener[*model.JSONMessageHeaderV1] {
return newMsgListener(hub, mailbox,
func(msg event.MessageMetadata) *model.JSONMessageHeaderV1 {
return metadataToHeader(&msg)
},
nil)
}
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
// the client of all messages received.
func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgrader.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 := newMsgListener(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
w http.ResponseWriter, req *http.Request, ctx *web.Context) error {
return serveMonitor(w, req, ctx.MsgHub, "", newMsgListenerV1)
}
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
// notifies the client of messages received by a particular mailbox.
func MonitorMailboxMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
w http.ResponseWriter, req *http.Request, ctx *web.Context) error {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket.
conn, err := upgrader.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 := newMsgListener(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV1)
}
+49
View File
@@ -0,0 +1,49 @@
package rest
import (
"net/http"
"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"
)
// newMsgListenerV2 creates a v2 API listener which relays message-stored and message-deleted
// events.
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListener[*model.JSONMonitorEventV2] {
return newMsgListener(hub, mailbox,
func(msg event.MessageMetadata) *model.JSONMonitorEventV2 {
return &model.JSONMonitorEventV2{
Variant: "message-stored",
Header: metadataToHeader(&msg),
}
},
func(mailbox string, id string) *model.JSONMonitorEventV2 {
return &model.JSONMonitorEventV2{
Variant: "message-deleted",
Identifier: &model.JSONMessageIDV2{
Mailbox: mailbox,
ID: id,
},
}
})
}
// 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) error {
return serveMonitor(w, req, ctx.MsgHub, "", newMsgListenerV2)
}
// 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) error {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV2)
}
+30 -14
View File
@@ -2,42 +2,56 @@ package rest
import (
"bytes"
"context"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/server/web"
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("GET", url, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(body))
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func setupWebServer(mm message.Manager) *bytes.Buffer {
func setupWebServer(mm message.Manager, hub *msghub.Hub) *bytes.Buffer {
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
@@ -48,9 +62,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
UIDir: "../ui",
},
}
shutdownChan := make(chan bool)
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
web.NewServer(cfg, mm, hub)
return buf
}
@@ -111,12 +124,11 @@ func decodedStringEquals(t *testing.T, json interface{}, path string, want strin
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
// brackets require the parent element to be a []interface{}.
//
// getDecodedPath(o, "users", "[1]", "name")
// getDecodedPath(o, "users", "[1]", "name")
//
// is equivalent to the JavaScript:
//
// o.users[1].name
//
// o.users[1].name
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if len(path) == 0 {
return o, ""
@@ -124,9 +136,10 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if o == nil {
return nil, " is nil"
}
key := path[0]
present := false
var present bool
var val interface{}
key := path[0]
if key[0] == '[' {
// Expecting slice.
index, err := strconv.Atoi(strings.Trim(key, "[]"))
@@ -149,12 +162,15 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
}
val, present = omap[key]
}
if !present {
return nil, "/" + key + " is missing"
}
result, msg := getDecodedPath(val, path[1:]...)
if msg != "" {
return nil, "/" + key + msg
}
return result, ""
}
+131
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)
}
}
+192 -210
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/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -53,23 +55,25 @@ var commands = map[string]bool{
"PASS": true,
"APOP": true,
"CAPA": true,
"STLS": true,
}
// Session defines an active POP3 session
type Session struct {
*Server // Reference to the server we belong to.
id int // Session ID number.
conn net.Conn // Our network connection.
remoteHost string // IP address of client.
sendError error // Used to bail out of read loop on send error.
state State // Current session state.
reader *bufio.Reader // Buffered reader for our net conn.
user string // Mailbox name.
messages []storage.Message // Slice of messages in mailbox.
retain []bool // Messages to retain upon UPDATE (true=retain).
msgCount int // Number of undeleted messages.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
*Server // Reference to the server we belong to.
id int // Session ID number.
conn net.Conn // Our network connection.
remoteHost string // IP address of client.
sendError error // Used to bail out of read loop on send error.
state State // Current session state.
reader *bufio.Reader // Buffered reader for our net conn.
user string // Mailbox name.
messages []storage.Message // Slice of messages in mailbox.
retain []bool // Messages to retain upon UPDATE (true=retain).
msgCount int // Number of undeleted messages.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
tlsState *tls.ConnectionState // TLS connection state, nil until TLS is set up.
}
// NewSession creates a new POP3 session
@@ -99,64 +103,81 @@ 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
var tlsState *tls.ConnectionState
if s.config.ForceTLS {
logger.Debug().Msg("Setting up TLS for ForceTLS")
tlsConn := tls.Server(conn, s.tlsConfig)
tlsState = new(tls.ConnectionState)
*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()
}()
ssn := NewSession(s, id, conn, logger)
ssn.tlsState = tlsState
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
time.Now().Unix(), s.config.Domain))
// 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 && ssn.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 {
@@ -189,11 +210,46 @@ 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 {
// RFC 2595 §4: the negotiation must complete before any further
// commands are sent, so a failure is fatal to the session.
s.logger.Error().Msgf("TLS handshake failed: %v", err)
s.enterState(QUIT)
return
}
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]
@@ -243,101 +299,21 @@ func (s *Session) transactionHandler(cmd string, args []string) {
}
s.send(fmt.Sprintf("+OK %v %v", count, size))
case "LIST":
if len(args) > 1 {
s.logger.Warn().Msgf("LIST command had more than 1 argument")
s.send("-ERR LIST command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("LIST command argument was not an integer")
s.send("-ERR LIST command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("LIST command argument was less than 1")
s.send("-ERR LIST argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("LIST command argument was greater than number of messages")
s.send("-ERR LIST argument must not exceed the number of messages")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to LIST a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].Size()))
} else {
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, msg.Size()))
}
}
s.send(".")
}
s.listMessages("LIST", args, func(m storage.Message) string {
return strconv.FormatInt(m.Size(), 10)
})
case "UIDL":
if len(args) > 1 {
s.logger.Warn().Msgf("UIDL command had more than 1 argument")
s.send("-ERR UIDL command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("UIDL command argument was not an integer")
s.send("-ERR UIDL command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("UIDL command argument was less than 1")
s.send("-ERR UIDL argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("UIDL command argument was greater than number of messages")
s.send("-ERR UIDL argument must not exceed the number of messages")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to UIDL a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].ID()))
} else {
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
}
}
s.send(".")
}
s.listMessages("UIDL", args, func(m storage.Message) string {
return m.ID()
})
case "DELE":
if len(args) != 1 {
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
s.send("-ERR DELE command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("DELE command argument was not an integer")
s.send("-ERR DELE command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("DELE command argument was less than 1")
s.send("-ERR DELE argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("DELE command argument was greater than number of messages")
s.send("-ERR DELE argument must not exceed the number of messages")
msgNum, ok := s.validateMsgNum("DELE", "DELE", args[0])
if !ok {
return
}
if s.retain[msgNum-1] {
@@ -354,49 +330,30 @@ func (s *Session) transactionHandler(cmd string, args []string) {
s.send("-ERR RETR command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("RETR command argument was not an integer")
s.send("-ERR RETR command requires an integer argument")
msgNum, ok := s.validateMsgNum("RETR", "RETR", args[0])
if !ok {
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("RETR command argument was less than 1")
s.send("-ERR RETR argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("RETR command argument was greater than number of messages")
s.send("-ERR RETR argument must not exceed the number of messages")
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to RETR a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
s.sendMessage(s.messages[msgNum-1])
s.sendMessageLines(s.messages[msgNum-1], -1)
case "TOP":
if len(args) != 2 {
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
s.send("-ERR TOP command requires two arguments")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("TOP command first argument was not an integer")
s.send("-ERR TOP command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("TOP command first argument was less than 1")
s.send("-ERR TOP first argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("TOP command first argument was greater than number of messages")
s.send("-ERR TOP first argument must not exceed the number of messages")
msgNum, ok := s.validateMsgNum("TOP", "TOP first", args[0])
if !ok {
return
}
var lines int64
lines, err = strconv.ParseInt(args[1], 10, 32)
lines, err := strconv.ParseInt(args[1], 10, 32)
if err != nil {
s.logger.Warn().Msgf("TOP command second argument was not an integer")
s.send("-ERR TOP command requires an integer argument")
@@ -407,14 +364,19 @@ func (s *Session) transactionHandler(cmd string, args []string) {
s.send("-ERR TOP second argument must be non-negative")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to TOP a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send("+OK Top of message follows")
s.sendMessageTop(s.messages[msgNum-1], int(lines))
s.sendMessageLines(s.messages[msgNum-1], int(lines))
case "QUIT":
s.send("+OK We will process your deletes")
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")
@@ -425,41 +387,65 @@ func (s *Session) transactionHandler(cmd string, args []string) {
}
}
// Send the contents of the message to the client
func (s *Session) sendMessage(msg storage.Message) {
reader, err := msg.Source()
if err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send("-ERR Failed to RETR that message, internal error")
// listMessages implements the LIST and UIDL commands, which differ only in
// the value reported for each message.
func (s *Session) listMessages(cmd string, args []string, value func(m storage.Message) string) {
if len(args) > 1 {
s.logger.Warn().Msgf("%v command had more than 1 argument", cmd)
s.send(fmt.Sprintf("-ERR %v command must have zero or one argument", cmd))
return
}
defer func() {
if err := reader.Close(); err != nil {
s.logger.Error().Msgf("Failed to close message: %v", err)
if len(args) == 1 {
msgNum, ok := s.validateMsgNum(cmd, cmd, args[0])
if !ok {
return
}
}()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to %v a message it had deleted", cmd)
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(line)
}
if err = scanner.Err(); err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send(".")
s.send("-ERR Failed to RETR that message, internal error")
s.send(fmt.Sprintf("+OK %v %v", msgNum, value(s.messages[msgNum-1])))
return
}
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, value(msg)))
}
}
s.send(".")
}
// Send the headers plus the top N lines to the client
func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
// validateMsgNum parses arg as a message number and range-checks it against
// the messages in the mailbox, sending a -ERR response and returning ok=false
// when invalid. cmd labels the command in responses; argLabel labels the
// argument (e.g. "RETR", "TOP first").
func (s *Session) validateMsgNum(cmd, argLabel, arg string) (msgNum int, ok bool) {
n, err := strconv.ParseInt(arg, 10, 32)
if err != nil {
s.logger.Warn().Msgf("%v command argument was not an integer", cmd)
s.send(fmt.Sprintf("-ERR %v command requires an integer argument", cmd))
return 0, false
}
if n < 1 {
s.logger.Warn().Msgf("%v argument was less than 1", argLabel)
s.send(fmt.Sprintf("-ERR %v argument must be greater than 0", argLabel))
return 0, false
}
if int(n) > len(s.messages) {
s.logger.Warn().Msgf("%v argument was greater than number of messages", argLabel)
s.send(fmt.Sprintf("-ERR %v argument must not exceed the number of messages", argLabel))
return 0, false
}
return int(n), true
}
// sendMessageLines sends the raw contents of msg to the client, dot-stuffing
// any lines that begin with a period. If maxBodyLines is negative the entire
// message is sent; otherwise the headers (and separating blank line) plus at
// most maxBodyLines lines of the body are sent.
func (s *Session) sendMessageLines(msg storage.Message, maxBodyLines int) {
reader, err := msg.Source()
if err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
@@ -481,17 +467,13 @@ func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
line = "." + line
}
if inBody {
// Check if we need to send anymore lines
if lineCount < 1 {
if maxBodyLines >= 0 && maxBodyLines < 1 {
break
} else {
lineCount--
}
} else {
if line == "" {
// We've hit the end of the header
inBody = true
}
maxBodyLines--
} else if line == "" {
// We've hit the end of the header
inBody = true
}
s.send(line)
}
@@ -582,14 +564,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() {
+464
View File
@@ -0,0 +1,464 @@
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"
"github.com/stretchr/testify/require"
)
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)
}
}
// TestSTLSPerSession verifies that STLS state belongs to each session: a
// completed STLS negotiation must not affect later sessions on the same server.
func TestSTLSPerSession(t *testing.T) {
server := setupPOPServer(t, test.NewStore(), true, false)
for i := 1; i <= 2; i++ {
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, []scriptStep{
{"CAPA", []string{"+OK Capability list follows", "TOP", "USER", "UIDL", "IMPLEMENTATION Inbucket", "STLS", "."}},
{"STLS", []string{"+OK Begin TLS Negotiation"}},
})
tlsConn := tls.Client(clientConn, &tls.Config{InsecureSkipVerify: true})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
require.NoErrorf(t, tlsConn.HandshakeContext(ctx), "session %d: TLS handshake", i)
cancel()
playPOP3Script(t, textproto.NewConn(tlsConn), []scriptStep{
{"QUIT", []string{"+OK Goodnight and good luck"}},
})
}
}
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.")
}
}
}
// RFC 2595 §4: after the +OK response to STLS, a TLS negotiation begins
// immediately and the client must not issue further commands until it
// completes. A failed negotiation is therefore fatal to the session: the
// server must not keep the session running on the dead connection.
func TestSTLSHandshakeFailure(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")
}
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)
}
// Send bytes that cannot begin a TLS handshake.
if _, err := pipe.Write([]byte("this is not a TLS handshake\r\n")); err != nil {
t.Fatalf("Failed to write garbage; %v.", err)
}
// The server must terminate the session without further responses.
_, err = c.ReadLine()
if err == nil {
t.Fatal("Expected connection close after failed handshake")
}
}
// 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
}
+58 -37
View File
@@ -2,58 +2,81 @@ package pop3
import (
"context"
"crypto/tls"
"fmt"
"net"
"sync"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
"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.
}
// 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 {
reloader, err := tlscert.NewReloader(pop3Config.TLSCert, pop3Config.TLSPrivKey, slog)
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.
}
tlsConfig.GetCertificate = reloader.GetCertificate
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 +89,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 +111,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
}
+905
View File
@@ -0,0 +1,905 @@
package pop3
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/mail"
"net/textproto"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"testing/iotest"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// scriptStep describes one command/response exchange in a scripted POP3
// session. expect holds the exact response lines; multi-line responses include
// every line up to and including the terminating ".".
type scriptStep struct {
send string
expect []string
}
// playPOP3Session starts a fresh session against server, consumes the greeting,
// and plays script against it.
func playPOP3Session(t *testing.T, server *Server, script []scriptStep) {
t.Helper()
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, script)
}
// startPOP3Session runs startSession on serverConn, arranging cleanup so the
// server is drained before the test completes.
func startPOP3Session(t *testing.T, server *Server, serverConn net.Conn) {
t.Helper()
server.wg.Add(1)
sessionNum++
go server.startSession(context.Background(), sessionNum, serverConn)
t.Cleanup(server.Drain)
}
func readPOP3Greeting(t *testing.T, c *textproto.Conn) {
t.Helper()
greeting, err := c.ReadLine()
require.NoError(t, err, "reading server greeting")
require.True(t, strings.HasPrefix(greeting, "+OK"), "expected +OK greeting, got %q", greeting)
}
// playPOP3Script sends each step and compares the exact response lines.
func playPOP3Script(t *testing.T, c *textproto.Conn, script []scriptStep) {
t.Helper()
for i, step := range script {
require.NoErrorf(t, c.PrintfLine("%s", step.send), "step %d: sending %q", i, step.send)
for j, want := range step.expect {
got, err := c.ReadLine()
require.NoErrorf(t, err, "step %d (%q): reading response line %d", i, step.send, j)
require.Equalf(t, want, got, "step %d (%q): response line %d", i, step.send, j)
}
}
}
// loginSteps returns script steps authenticating as "mailbox"; count is the
// number of messages the greeting is expected to report.
func loginSteps(count int) []scriptStep {
return []scriptStep{
{"USER mailbox", []string{"+OK Hello mailbox, welcome to Inbucket"}},
{"PASS anything", []string{fmt.Sprintf("+OK Found %v messages for mailbox", count)}},
}
}
// newMemStore returns an empty memory store; it assigns deterministic
// sequential message IDs ("1", "2", ...).
func newMemStore(t *testing.T) storage.Store {
t.Helper()
store, err := mem.New(config.Storage{}, extension.NewHost())
require.NoError(t, err)
return store
}
// deliverRaw stores a message with the exact raw content provided, returning
// its size.
func deliverRaw(t *testing.T, store storage.Store, mailbox, raw string) int64 {
t.Helper()
delivery := &message.Delivery{
Meta: event.MessageMetadata{
Mailbox: mailbox,
Subject: "raw",
},
Reader: io.NopCloser(strings.NewReader(raw)),
}
_, err := store.AddMessage(delivery)
require.NoError(t, err)
return int64(len(raw))
}
// fakeMsg is a storage.Message with canned metadata, used to exercise RETR/TOP
// source error paths.
type fakeMsg struct {
mailbox string
id string
size int64
source func() (io.ReadCloser, error)
}
func (m *fakeMsg) Mailbox() string { return m.mailbox }
func (m *fakeMsg) ID() string { return m.id }
func (m *fakeMsg) From() *mail.Address { return nil }
func (m *fakeMsg) To() []*mail.Address { return nil }
func (m *fakeMsg) Date() time.Time { return time.Time{} }
func (m *fakeMsg) Subject() string { return "fake" }
func (m *fakeMsg) Size() int64 { return m.size }
func (m *fakeMsg) Seen() bool { return false }
func (m *fakeMsg) Source() (io.ReadCloser, error) { return m.source() }
// readDeadlineErrorConn fails SetReadDeadline, to exercise the read error
// paths of the session read loop.
type readDeadlineErrorConn struct {
net.Conn
err error
}
func (c *readDeadlineErrorConn) SetReadDeadline(time.Time) error { return c.err }
// fakeNetError is a net.Error with a configurable Timeout() result.
type fakeNetError struct {
timeout bool
}
func (e fakeNetError) Error() string { return "fake network error" }
func (e fakeNetError) Timeout() bool { return e.timeout }
func (e fakeNetError) Temporary() bool { return false }
func TestAuthorizationCommandErrors(t *testing.T) {
server := setupPOPServer(t, newMemStore(t), false, false)
tcs := []struct {
name string
script []scriptStep
}{
{
name: "USER without argument",
script: []scriptStep{{"USER", []string{"-ERR Missing username argument"}}},
},
{
name: "PASS before USER",
script: []scriptStep{{"PASS sekrit", []string{"-ERR Command PASS is out of sequence"}}},
},
{
name: "transaction command before login",
script: []scriptStep{{"STAT", []string{"-ERR Command STAT is out of sequence"}}},
},
{
name: "APOP with one argument",
script: []scriptStep{{"APOP mailbox", []string{"-ERR APOP requires two arguments"}}},
},
{
name: "unknown command",
script: []scriptStep{{"FROBNICATE now", []string{"-ERR Syntax error, FROBNICATE command unrecognized"}}},
},
{
name: "empty command",
script: []scriptStep{{"", []string{"-ERR Speak up"}}},
},
{
name: "QUIT",
script: []scriptStep{{"QUIT", []string{"+OK Goodnight and good luck"}}},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, tc.script)
})
}
}
func TestAuthorizationApopLogin(t *testing.T) {
server := setupPOPServer(t, newMemStore(t), false, false)
script := []scriptStep{
{"APOP mailbox digest", []string{"+OK Found 0 messages for mailbox"}},
{"STAT", []string{"+OK 0 0"}},
{"QUIT", []string{"+OK We will process your deletes"}},
}
playPOP3Session(t, server, script)
}
func TestTransactionStat(t *testing.T) {
ds := newMemStore(t)
_, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
_, size2 := test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"STAT", []string{fmt.Sprintf("+OK %v %v", 2, size1+size2)}},
{"DELE 1", []string{"+OK Deleted message 1"}},
{"STAT", []string{fmt.Sprintf("+OK %v %v", 1, size2)}},
{"RSET", []string{"+OK Session reset"}},
{"STAT", []string{fmt.Sprintf("+OK %v %v", 2, size1+size2)}},
{"STAT 1", []string{"-ERR STAT command must have no arguments"}},
{"NOOP", []string{"+OK I have successfully done nothing"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionList(t *testing.T) {
ds := newMemStore(t)
_, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
_, size2 := test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"LIST", []string{"+OK Listing 2 messages",
fmt.Sprintf("1 %v", size1), fmt.Sprintf("2 %v", size2), "."}},
{"LIST 2", []string{fmt.Sprintf("+OK 2 %v", size2)}},
{"DELE 1", []string{"+OK Deleted message 1"}},
{"LIST", []string{"+OK Listing 1 messages", fmt.Sprintf("2 %v", size2), "."}},
{"LIST 1", []string{"-ERR You deleted message 1"}},
{"CAPA", []string{"+OK Capability list follows",
"TOP", "USER", "UIDL", "IMPLEMENTATION Inbucket", "."}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionListArgumentErrors(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
tcs := []struct {
name string
script []scriptStep
}{
{"two arguments", []scriptStep{{"LIST 1 2", []string{"-ERR LIST command must have zero or one argument"}}}},
{"not an integer", []scriptStep{{"LIST x", []string{"-ERR LIST command requires an integer argument"}}}},
{"zero", []scriptStep{{"LIST 0", []string{"-ERR LIST argument must be greater than 0"}}}},
{"negative", []scriptStep{{"LIST -1", []string{"-ERR LIST argument must be greater than 0"}}}},
{"out of range", []scriptStep{{"LIST 2", []string{"-ERR LIST argument must not exceed the number of messages"}}}},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script))
})
}
}
func TestTransactionUidl(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"UIDL", []string{"+OK Listing 2 messages", "1 1", "2 2", "."}},
{"UIDL 2", []string{"+OK 2 2"}},
{"DELE 2", []string{"+OK Deleted message 2"}},
{"UIDL", []string{"+OK Listing 1 messages", "1 1", "."}},
{"UIDL 2", []string{"-ERR You deleted message 2"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionUidlArgumentErrors(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
tcs := []struct {
name string
script []scriptStep
}{
{"two arguments", []scriptStep{{"UIDL 1 2", []string{"-ERR UIDL command must have zero or one argument"}}}},
{"not an integer", []scriptStep{{"UIDL x", []string{"-ERR UIDL command requires an integer argument"}}}},
{"zero", []scriptStep{{"UIDL 0", []string{"-ERR UIDL argument must be greater than 0"}}}},
{"out of range", []scriptStep{{"UIDL 2", []string{"-ERR UIDL argument must not exceed the number of messages"}}}},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script))
})
}
}
func TestTransactionDeleAndRset(t *testing.T) {
ds := newMemStore(t)
_, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"DELE 2", []string{"+OK Deleted message 2"}},
{"DELE 2", []string{"-ERR Message 2 has already been deleted"}},
{"STAT", []string{fmt.Sprintf("+OK 1 %v", size1)}},
{"RSET", []string{"+OK Session reset"}},
{"DELE 2", []string{"+OK Deleted message 2"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionDeleArgumentErrors(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
tcs := []struct {
name string
script []scriptStep
}{
{"no argument", []scriptStep{{"DELE", []string{"-ERR DELE command requires a single argument"}}}},
{"two arguments", []scriptStep{{"DELE 1 2", []string{"-ERR DELE command requires a single argument"}}}},
{"not an integer", []scriptStep{{"DELE x", []string{"-ERR DELE command requires an integer argument"}}}},
{"zero", []scriptStep{{"DELE 0", []string{"-ERR DELE argument must be greater than 0"}}}},
{"out of range", []scriptStep{{"DELE 2", []string{"-ERR DELE argument must not exceed the number of messages"}}}},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script))
})
}
}
func TestTransactionQuitProcessesDeletes(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, slices.Concat(loginSteps(2), []scriptStep{
{"DELE 1", []string{"+OK Deleted message 1"}},
{"QUIT", []string{"+OK We will process your deletes"}},
}))
// The deletes are processed before the session goroutine completes.
server.Drain()
msgs := test.GetAndCountMessages(t, ds, "mailbox", 1)
assert.Equal(t, "2", msgs[0].ID())
}
func TestTransactionQuitPreservesMessagesAfterRset(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, slices.Concat(loginSteps(2), []scriptStep{
{"DELE 1", []string{"+OK Deleted message 1"}},
{"RSET", []string{"+OK Session reset"}},
{"QUIT", []string{"+OK We will process your deletes"}},
}))
server.Drain()
test.GetAndCountMessages(t, ds, "mailbox", 2)
}
func TestTransactionQuitWithoutDeletes(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, slices.Concat(loginSteps(1), []scriptStep{
{"QUIT", []string{"+OK We will process your deletes"}},
}))
server.Drain()
test.GetAndCountMessages(t, ds, "mailbox", 1)
}
func TestTransactionRetr(t *testing.T) {
ds := newMemStore(t)
_, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"RETR 1", []string{fmt.Sprintf("+OK %v bytes follows", size1),
"To: somebody@host", "From: somebodyelse@host", "Subject: One", "", "Test Body", "."}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionRetrArgumentErrors(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
tcs := []struct {
name string
script []scriptStep
}{
{"no argument", []scriptStep{{"RETR", []string{"-ERR RETR command requires a single argument"}}}},
{"two arguments", []scriptStep{{"RETR 1 2", []string{"-ERR RETR command requires a single argument"}}}},
{"not an integer", []scriptStep{{"RETR x", []string{"-ERR RETR command requires an integer argument"}}}},
{"zero", []scriptStep{{"RETR 0", []string{"-ERR RETR argument must be greater than 0"}}}},
{"out of range", []scriptStep{{"RETR 2", []string{"-ERR RETR argument must not exceed the number of messages"}}}},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script))
})
}
}
// RFC 1939 §5: a RETR message-number may NOT refer to a message marked as
// deleted; the server responds with a negative status indicator.
func TestTransactionRetrDeletedMessageRejected(t *testing.T) {
ds := newMemStore(t)
_, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
test.DeliverToStore(t, ds, "mailbox", "Two", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(2), []scriptStep{
{"DELE 2", []string{"+OK Deleted message 2"}},
{"RETR 2", []string{"-ERR You deleted message 2"}},
// A retained message still downloads fine.
{"RETR 1", []string{fmt.Sprintf("+OK %v bytes follows", size1),
"To: somebody@host", "From: somebodyelse@host", "Subject: One", "", "Test Body", "."}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionTop(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
headers := []string{"To: somebody@host", "From: somebodyelse@host", "Subject: One"}
script := slices.Concat(loginSteps(1), []scriptStep{
// Zero body lines: headers plus the blank separator line only.
{"TOP 1 0", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{""}, []string{"."})},
// One body line.
{"TOP 1 1", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{"", "Test Body", "."})},
// More body lines than the message contains.
{"TOP 1 99", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{"", "Test Body", "."})},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionTopArgumentErrors(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
tcs := []struct {
name string
script []scriptStep
}{
{"one argument", []scriptStep{{"TOP 1", []string{"-ERR TOP command requires two arguments"}}}},
{"three arguments", []scriptStep{{"TOP 1 2 3", []string{"-ERR TOP command requires two arguments"}}}},
{"message not an integer", []scriptStep{{"TOP x 1", []string{"-ERR TOP command requires an integer argument"}}}},
{"message zero", []scriptStep{{"TOP 0 1", []string{"-ERR TOP first argument must be greater than 0"}}}},
{"message out of range", []scriptStep{{"TOP 2 1", []string{"-ERR TOP first argument must not exceed the number of messages"}}}},
{"lines not an integer", []scriptStep{{"TOP 1 x", []string{"-ERR TOP command requires an integer argument"}}}},
{"lines negative", []scriptStep{{"TOP 1 -1", []string{"-ERR TOP second argument must be non-negative"}}}},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script))
})
}
}
// RFC 1939 §7 (TOP): a message-number may NOT refer to a message marked as
// deleted.
func TestTransactionTopDeletedMessageRejected(t *testing.T) {
ds := newMemStore(t)
test.DeliverToStore(t, ds, "mailbox", "One", time.Now())
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(1), []scriptStep{
{"DELE 1", []string{"+OK Deleted message 1"}},
{"TOP 1 0", []string{"-ERR You deleted message 1"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionRetrDotStuffing(t *testing.T) {
ds := newMemStore(t)
size := deliverRaw(t, ds, "mailbox", "Subject: dots\r\n\r\n.leading\r\n..double\r\n.\r\nplain\r\n")
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(1), []scriptStep{
// Each leading period is doubled; a lone "." line becomes "..".
{"RETR 1", []string{fmt.Sprintf("+OK %v bytes follows", size),
"Subject: dots", "", "..leading", "...double", "..", "plain", "."}},
{"TOP 1 0", []string{"+OK Top of message follows", "Subject: dots", "", "."}},
{"TOP 1 2", []string{"+OK Top of message follows",
"Subject: dots", "", "..leading", "...double", "."}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionRetrSourceError(t *testing.T) {
ds := test.NewStore()
_, err := ds.AddMessage(&fakeMsg{
mailbox: "mailbox", id: "m1", size: 100,
source: func() (io.ReadCloser, error) { return nil, errors.New("source unavailable") },
})
require.NoError(t, err)
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(1), []scriptStep{
{"RETR 1", []string{"+OK 100 bytes follows",
"-ERR Failed to RETR that message, internal error"}},
{"TOP 1 0", []string{"+OK Top of message follows",
"-ERR Failed to RETR that message, internal error"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
func TestTransactionRetrReadError(t *testing.T) {
ds := test.NewStore()
_, err := ds.AddMessage(&fakeMsg{
mailbox: "mailbox", id: "m1", size: 100,
source: func() (io.ReadCloser, error) {
return io.NopCloser(iotest.ErrReader(errors.New("short read"))), nil
},
})
require.NoError(t, err)
server := setupPOPServer(t, ds, false, false)
script := slices.Concat(loginSteps(1), []scriptStep{
// A read failure mid-message terminates the multi-line response, then
// reports the error.
{"RETR 1", []string{"+OK 100 bytes follows", ".",
"-ERR Failed to RETR that message, internal error"}},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
// Documents current behavior: errors loading the mailbox are logged but not
// reported to the client, which sees an empty mailbox.
func TestTransactionLoadMailboxError(t *testing.T) {
server := setupPOPServer(t, test.NewStore(), false, false)
script := []scriptStep{
{"USER messageserr", []string{"+OK Hello messageserr, welcome to Inbucket"}},
{"PASS anything", []string{"+OK Found 0 messages for messageserr"}},
{"STAT", []string{"+OK 0 0"}},
{"LIST", []string{"+OK Listing 0 messages", "."}},
{"UIDL", []string{"+OK Listing 0 messages", "."}},
{"QUIT", []string{"+OK We will process your deletes"}},
}
playPOP3Session(t, server, script)
}
func TestSessionString(t *testing.T) {
server := setupPOPServer(t, test.NewStore(), false, false)
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
_ = clientConn.Close()
_ = serverConn.Close()
})
ssn := NewSession(server, 7, &mockConn{serverConn}, zerolog.New(zerolog.NewTestWriter(t)))
assert.Equal(t, "Session{id: 7, state: AUTHORIZATION}", ssn.String())
}
func TestReadDeadlineError(t *testing.T) {
tcs := []struct {
name string
err error
want string
}{
{"timeout", fakeNetError{timeout: true}, "-ERR Idle timeout, bye bye"},
{"other error", fakeNetError{timeout: false}, "-ERR Connection error, sorry"},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
server := setupPOPServer(t, newMemStore(t), false, false)
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &readDeadlineErrorConn{serverConn, tc.err})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
line, err := c.ReadLine()
require.NoError(t, err, "reading error response")
assert.Equal(t, tc.want, line)
// The session should be closed.
_, err = c.ReadLine()
assert.Error(t, err, "expected connection to be closed")
})
}
}
func TestServerStartServeAndQuit(t *testing.T) {
server, err := NewServer(config.POP3{
Addr: "127.0.0.1:0",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
}, test.NewStore())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
ready := make(chan struct{})
started := make(chan struct{})
go func() {
defer close(started)
server.Start(ctx, func() { close(ready) })
}()
<-ready
t.Cleanup(func() {
cancel()
<-started
server.Drain()
})
conn, err := (&net.Dialer{}).DialContext(context.Background(), "tcp", server.listener.Addr().String())
require.NoError(t, err)
c := textproto.NewConn(conn)
readPOP3Greeting(t, c)
require.NoError(t, c.PrintfLine("QUIT"))
line, err := c.ReadLine()
require.NoError(t, err)
assert.Equal(t, "+OK Goodnight and good luck", line)
require.NoError(t, conn.Close())
}
func TestServerStartAddressErrors(t *testing.T) {
// Reserve a port to force a bind failure.
reserved, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = reserved.Close() })
tcs := []struct {
name string
addr string
}{
{"invalid port", "127.0.0.1:notaport"},
{"port in use", reserved.Addr().String()},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
server, err := NewServer(config.POP3{
Addr: tc.addr,
Domain: "inbucket.local",
Timeout: 5 * time.Second,
}, test.NewStore())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go server.Start(ctx, func() { t.Error("readyFunc must not be called") })
select {
case err := <-server.Notify():
require.Error(t, err)
case <-time.After(10 * time.Second):
t.Fatal("expected a fatal error notification")
}
})
}
}
func TestServerAcceptErrorNotifies(t *testing.T) {
server, err := NewServer(config.POP3{
Addr: "127.0.0.1:0",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
}, test.NewStore())
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
ready := make(chan struct{})
started := make(chan struct{})
go func() {
defer close(started)
server.Start(ctx, func() { close(ready) })
}()
<-ready
t.Cleanup(func() {
cancel()
<-started
server.Drain()
})
// Closing the listener without cancelling the context makes serve()
// report the accept error via Notify.
require.NoError(t, server.listener.Close())
select {
case err := <-server.Notify():
require.Error(t, err)
case <-time.After(10 * time.Second):
t.Fatal("expected a fatal error notification")
}
}
func TestNewServerCertificateError(t *testing.T) {
_, err := NewServer(config.POP3{
Addr: "127.0.0.1:1100",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
TLSEnabled: true,
TLSCert: "/nonexistent/cert.pem",
TLSPrivKey: "/nonexistent/key.pem",
}, test.NewStore())
require.Error(t, err)
}
// TestForceTLSServesRotatedCertificate verifies that replacing the certificate files on disk
// changes the certificate presented by later full handshakes, without restarting the server,
// while a client resuming an earlier TLS session keeps that session's certificate.
func TestForceTLSServesRotatedCertificate(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writeKeyPair := func(version int) []byte {
cert, privKey, err := generateCertificate(t)
require.NoError(t, err)
mtime := time.Date(2026, 1, 1, 0, 0, version, 0, time.UTC)
require.NoError(t, os.WriteFile(certPath, certToPem(cert), 0o600))
require.NoError(t, os.WriteFile(keyPath, privKeyToPem(privKey), 0o600))
require.NoError(t, os.Chtimes(certPath, mtime, mtime))
require.NoError(t, os.Chtimes(keyPath, mtime, mtime))
return cert
}
first := writeKeyPair(1)
server, err := NewServer(config.POP3{
Addr: "127.0.0.1:0",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
ForceTLS: true,
TLSEnabled: true,
TLSCert: certPath,
TLSPrivKey: keyPath,
}, test.NewStore())
require.NoError(t, err)
t.Cleanup(server.Drain)
// peerCertificate starts a session and returns the certificate the server presents, and
// whether the TLS session was resumed.
peerCertificate := func(tlsConfig *tls.Config) ([]byte, bool) {
tlsConn := tls.Client(setupPOPSession(t, server), tlsConfig)
defer func() { _ = tlsConn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, tlsConn.HandshakeContext(ctx))
// Read the greeting so the server is waiting on input when the client closes. Reading
// also processes the session ticket the server sends after the handshake.
readPOP3Greeting(t, textproto.NewConn(tlsConn))
state := tlsConn.ConnectionState()
return state.PeerCertificates[0].Raw, state.DidResume
}
fresh := &tls.Config{InsecureSkipVerify: true}
caching := &tls.Config{InsecureSkipVerify: true, ClientSessionCache: tls.NewLRUClientSessionCache(1)}
cert, _ := peerCertificate(fresh)
assert.Equal(t, first, cert)
cert, _ = peerCertificate(caching)
assert.Equal(t, first, cert)
second := writeKeyPair(2)
cert, _ = peerCertificate(fresh)
assert.Equal(t, second, cert, "full handshake after rotation")
cert, resumed := peerCertificate(caching)
assert.True(t, resumed, "caching client should resume its session")
assert.Equal(t, first, cert, "resumed session after rotation")
}
// TestValidateMsgNum exercises the shared message-number validation helper
// directly.
func TestValidateMsgNum(t *testing.T) {
server := setupPOPServer(t, newMemStore(t), false, false)
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
// Drain server->client output so sends do not block.
output := make(chan string, 16)
go func() {
sc := bufio.NewScanner(clientConn)
for sc.Scan() {
output <- sc.Text()
}
close(output)
}()
ssn := NewSession(server, 1, &mockConn{serverConn}, zerolog.New(zerolog.NewTestWriter(t)))
ssn.messages = make([]storage.Message, 3)
ssn.retainAll()
tcs := []struct {
name string
arg string
wantNum int
wantOK bool
wantSend string
}{
{name: "first message", arg: "1", wantNum: 1, wantOK: true},
{name: "last message", arg: "3", wantNum: 3, wantOK: true},
{name: "not an integer", arg: "x", wantSend: "-ERR TEST command requires an integer argument"},
{name: "integer overflow", arg: "9999999999999", wantSend: "-ERR TEST command requires an integer argument"},
{name: "zero", arg: "0", wantSend: "-ERR TEST argument must be greater than 0"},
{name: "negative", arg: "-1", wantSend: "-ERR TEST argument must be greater than 0"},
{name: "exceeds message count", arg: "4", wantSend: "-ERR TEST argument must not exceed the number of messages"},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
num, ok := ssn.validateMsgNum("TEST", "TEST", tc.arg)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.wantNum, num)
if tc.wantSend != "" {
select {
case line := <-output:
assert.Equal(t, tc.wantSend, line)
case <-time.After(time.Second):
t.Error("expected an error response")
}
}
})
}
}
// TestTopUnlimitedLinesMatchesRetr guards the shared implementation of RETR
// and TOP: a TOP line limit larger than the message must produce the same
// output as RETR.
func TestTopUnlimitedLinesMatchesRetr(t *testing.T) {
ds := newMemStore(t)
size := deliverRaw(t, ds, "mailbox", "Subject: equivalence\r\n\r\nline 1\r\nline 2\r\n.leading\r\n")
server := setupPOPServer(t, ds, false, false)
body := []string{"Subject: equivalence", "", "line 1", "line 2", "..leading", "."}
retrExpect := slices.Concat([]string{fmt.Sprintf("+OK %v bytes follows", size)}, body)
topExpect := slices.Concat([]string{"+OK Top of message follows"}, body)
script := slices.Concat(loginSteps(1), []scriptStep{
{"RETR 1", retrExpect},
{"TOP 1 999999", topExpect},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}
+203 -120
View File
@@ -4,27 +4,26 @@ import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/mail"
"net/textproto"
"regexp"
"strconv"
"strings"
"time"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our SMTP state machine.
type State int
const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
@@ -54,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 {
@@ -106,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.
@@ -117,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")
@@ -171,13 +182,13 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
line, err := ssn.readLine()
if err == nil {
//Handle LOGIN/PASSWORD states here, because they don't expect a command
// Handle LOGIN/PASSWORD states here, because they don't expect a command.
switch ssn.state {
case LOGIN:
ssn.loginHandler(line)
ssn.loginHandler()
continue
case PASSWORD:
ssn.passwordHandler(line)
ssn.passwordHandler()
continue
}
@@ -204,7 +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
@@ -289,7 +300,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
if s.config.TLSEnabled && !s.config.ForceTLS && s.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
@@ -305,18 +316,18 @@ func parseHelloArgument(arg string) (string, error) {
domain = arg[:idx]
}
if domain == "" {
return "", fmt.Errorf("Invalid domain")
return "", errors.New("invalid domain")
}
return domain, nil
}
func (s *Session) loginHandler(line string) {
func (s *Session) loginHandler() {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler(line string) {
func (s *Session) passwordHandler() {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
@@ -325,8 +336,9 @@ func (s *Session) passwordHandler(line string) {
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
switch cmd {
case "STARTTLS":
if !s.config.TLSEnabled {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
s.send("454 TLS unavailable on the server")
@@ -342,94 +354,130 @@ func (s *Session) readyHandler(cmd string, arg string) {
// Start TLS connection handshake.
s.send("220 STARTTLS")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
tlsConn := tls.Server(s.conn, s.tlsConfig)
s.conn = tlsConn
s.text = textproto.NewConn(s.conn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
} else if cmd == "AUTH" {
case "AUTH":
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
case "LOGIN":
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
default:
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else if cmd == "EHLO" {
case "MAIL":
s.parseMailFromCmd(arg)
case "EHLO":
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
} else {
default:
s.ooSeq(cmd)
}
}
// Parses `MAIL FROM` command.
func (s *Session) parseMailFromCmd(arg string) {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
// Parse ESMTP parameters.
if m[2] != "" {
// Here the client may put BODY=8BITMIME, but Inbucket already
// reads the DATA as bytes, so it does not effect mail processing.
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
// Reject oversized messages.
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
// Parse origin (from) address.
origin, err := s.addrPolicy.ParseOrigin(from)
if err != nil {
s.send("501 Bad origin address syntax")
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
return
}
// Add from to extSession for inspection.
extSession := s.extSession()
addrCopy := origin.Address
extSession.From = &addrCopy
// Process through extensions.
extAction := event.ActionDefer
extResult := s.extHost.Events.BeforeMailFromAccepted.Emit(extSession)
if extResult != nil {
extAction = extResult.Action
}
if extAction == event.ActionDeny {
s.send(fmt.Sprintf("%03d %s", extResult.ErrorCode, extResult.ErrorMsg))
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
return
}
// Sender was permitted by an extension, or no extension rejected it.
s.from = origin
// Ignore ShouldAccept if extensions explicitly allowed this From.
if extAction == event.ActionDefer && !s.from.ShouldAccept() {
s.send("501 Unauthorized domain")
s.logger.Warn().Msgf("Bad domain sender %s", origin.Domain)
return
}
// Ok to transition to MAIL state.
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
}
// MAIL state -> waiting for RCPTs followed by DATA
func (s *Session) mailHandler(cmd string, arg string) {
switch cmd {
@@ -446,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
@@ -499,31 +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)
// Generate Received header; Deliver() will append recipient and timestamp to this.
recvdHeader := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
// Deliver message.
if err := s.manager.Deliver(s.from, s.recipients, recvdHeader, mailData.Bytes()); err != nil {
// Deliver() logs failure details, and the effected mailbox.
s.send("451 Failed to store message")
s.reset()
return
}
// TODO Consider changing this to just 1 regardless of # of recipents.
expReceivedTotal.Add(int64(len(s.recipients)))
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
s.reset()
return
}
func (s *Session) enterState(state State) {
@@ -588,6 +648,7 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
s.logger.Debug().Msgf("Line received: %v", line)
// Find length of command or entire line.
hasArg := true
@@ -615,11 +676,13 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
// " BODY=8BITMIME SIZE=1024"
//
// " BODY=8BITMIME SIZE=1024"
//
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
@@ -634,7 +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
}
@@ -642,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,
}
}
+452 -216
View File
@@ -1,22 +1,24 @@
package smtp
import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"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,86 +67,45 @@ func TestGreetState(t *testing.T) {
{"hello", 500},
{"Outlook", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Valid HELOs
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test commands in READY state
// 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 empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
playSession(t, server, script)
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
playSession(t, server, script)
}
// Test AUTH
// Test AUTH commands.
func TestAuth(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
server := setupSMTPServer(ds, extension.NewHost())
// PLAIN AUTH
script := []scriptStep{
@@ -131,9 +118,7 @@ func TestAuth(t *testing.T) {
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// LOGIN AUTH
script = []scriptStep{
@@ -146,27 +131,85 @@ func TestAuth(t *testing.T) {
{"", 334},
{"", 235},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
playSession(t, server, script)
}
// Test TLS commands.
func TestTLS(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// Test Start TLS parsing.
script := []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
playSession(t, server, script)
}
// Test valid commands in READY state.
func TestReadyStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some valid MAIL commands
tests := []scriptStep{
{"MAIL FROM:<john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test commands in READY state
func TestReadyState(t *testing.T) {
// Test invalid domains in READY state.
func TestReadyStateRejectedDomains(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some mangled READY commands
script := []scriptStep{
{"HELO localhost", 250},
tests := []scriptStep{
{"MAIL FROM: <john@validdomain.com>", 250},
{"MAIL FROM: <john@invalidomain.com>", 501},
{"MAIL FROM: <john@s1.otherinvaliddomain.com>", 501},
{"MAIL FROM: <john@s2.otherinvaliddomain.com>", 501},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid commands in READY state.
func TestReadyStateInvalidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"FOOB", 500},
{"HELO", 503},
{"DATA", 503},
@@ -178,59 +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)
}
// Test Start TLS parsing.
script = []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test commands in MAIL state
func TestMailState(t *testing.T) {
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
server := setupSMTPServer(mds, extension.NewHost())
// Test out some mangled READY commands
script := []scriptStep{
@@ -246,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{
@@ -262,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{
@@ -278,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{
@@ -290,9 +292,7 @@ func TestMailState(t *testing.T) {
{"DATA", 354},
{".", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test late EHLO, similar to RSET
script = []scriptStep{
@@ -303,9 +303,7 @@ func TestMailState(t *testing.T) {
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
playSession(t, server, script)
// Test RSET
script = []scriptStep{
@@ -315,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{
@@ -326,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 {
@@ -357,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
@@ -373,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)
@@ -386,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)
@@ -445,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
@@ -461,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
}
+53 -46
View File
@@ -9,10 +9,12 @@ import (
"sync"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/metric"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/metric"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
"github.com/rs/zerolog/log"
)
@@ -58,69 +60,80 @@ 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{}
if smtpConfig.TLSEnabled {
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
reloader, err := tlscert.NewReloader(smtpConfig.TLSCert, smtpConfig.TLSPrivKey, slog)
if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
slog.Error().Msg("Disabling STARTTLS support")
smtpConfig.TLSEnabled = false
} else {
tlsConfig.GetCertificate = reloader.GetCertificate
slog.Debug().Msg("STARTTLS feature available")
}
}
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
}
+116
View File
@@ -0,0 +1,116 @@
// Package tlscert provides X509 key pairs that follow changes to their files on disk.
package tlscert
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
"github.com/rs/zerolog"
)
// retryInterval is the minimum time between attempts to reload a key pair that failed to load
// while its files are unchanged, such as when a chmod or chown fixes its permissions.
const retryInterval = 30 * time.Second
// fileState identifies a version of a file by its modification time and size.
type fileState struct {
modTime time.Time
size int64
}
// Reloader serves a certificate and private key loaded from files, reloading them when either
// file changes. It is safe for concurrent use.
type Reloader struct {
certFile string // X509 public certificate file.
keyFile string // X509 private key file.
logger zerolog.Logger // Logger for reload events.
retryInterval time.Duration // Minimum time between retries of a failed, unchanged pair.
now func() time.Time // Clock used to schedule retries.
mu sync.Mutex // Guards the fields below.
cert *tls.Certificate // Last successfully loaded key pair.
certState fileState // State of certFile at the last load attempt.
keyState fileState // State of keyFile at the last load attempt.
failedAt time.Time // Time of the last failed load attempt, zero after a success.
}
// NewReloader loads the key pair from certFile and keyFile, returning an error if it cannot be
// loaded.
func NewReloader(certFile, keyFile string, logger zerolog.Logger) (*Reloader, error) {
r := &Reloader{
certFile: certFile,
keyFile: keyFile,
logger: logger,
retryInterval: retryInterval,
now: time.Now,
}
certState, keyState, err := r.stat()
if err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
r.cert = &cert
r.certState = certState
r.keyState = keyState
return r, nil
}
// GetCertificate returns the current key pair, reloading it first if either file changed since
// the last load attempt, or if the last attempt failed and retryInterval has elapsed. Failures
// are logged and the previous key pair is served. crypto/tls calls it on full handshakes only.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.mu.Lock()
defer r.mu.Unlock()
// On error, stat returns zero states, so repeated stat failures count as unchanged files.
certState, keyState, err := r.stat()
changed := certState != r.certState || keyState != r.keyState
retryDue := !r.failedAt.IsZero() && r.now().Sub(r.failedAt) >= r.retryInterval
if !changed && !retryDue {
return r.cert, nil
}
r.certState = certState
r.keyState = keyState
if err == nil {
var cert tls.Certificate
cert, err = tls.LoadX509KeyPair(r.certFile, r.keyFile)
if err == nil {
r.cert = &cert
r.failedAt = time.Time{}
r.logger.Info().Msg("Reloaded X509 KeyPair")
return r.cert, nil
}
}
r.failedAt = r.now()
r.logger.Warn().Err(err).Msg("Failed to reload X509 KeyPair, serving previous certificate")
return r.cert, nil
}
// stat returns the current state of the certificate and key files, or zero states and an error
// if either cannot be read.
func (r *Reloader) stat() (certState, keyState fileState, err error) {
certState, err = statFile(r.certFile)
if err != nil {
return fileState{}, fileState{}, err
}
keyState, err = statFile(r.keyFile)
if err != nil {
return fileState{}, fileState{}, err
}
return certState, keyState, nil
}
func statFile(name string) (fileState, error) {
info, err := os.Stat(name)
if err != nil {
return fileState{}, fmt.Errorf("stat %s: %w", name, err)
}
return fileState{modTime: info.ModTime(), size: info.Size()}, nil
}
+254
View File
@@ -0,0 +1,254 @@
package tlscert
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewReloaderMissingFiles(t *testing.T) {
dir := t.TempDir()
_, err := NewReloader(filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem"), testLogger(t))
require.Error(t, err)
}
func TestNewReloaderMismatchedPair(t *testing.T) {
dir := t.TempDir()
certA, _ := generatePair(t, "a")
_, keyB := generatePair(t, "b")
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writeFile(t, certFile, certA, 1)
writeFile(t, keyFile, keyB, 1)
_, err := NewReloader(certFile, keyFile, testLogger(t))
require.Error(t, err)
}
func TestGetCertificateReloadsOnChange(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, certFile, keyFile, "b", 2)
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateKeepsPreviousOnFailure(t *testing.T) {
tcs := []struct {
name string
mangle func(t *testing.T, certFile, keyFile string)
}{
{
name: "invalid certificate",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
writeFile(t, certFile, []byte("not a certificate"), 2)
},
},
{
name: "certificate rotated before key",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
certB, _ := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
},
},
{
name: "files removed",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
require.NoError(t, os.Remove(certFile))
require.NoError(t, os.Remove(keyFile))
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
tc.mangle(t, certFile, keyFile)
assert.Equal(t, "a", commonName(t, r))
// A valid pair written afterward is picked up.
writePairFiles(t, certFile, keyFile, "c", 3)
assert.Equal(t, "c", commonName(t, r))
})
}
}
// TestGetCertificateRetriesUnchangedFiles covers a pair that fails to load and is then fixed
// without changing its files' modification time or size, as a chmod or chown would.
func TestGetCertificateRetriesUnchangedFiles(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
// The new key is unreadable on the first attempt.
certB, keyB := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
writeFile(t, keyFile, bytes.Repeat([]byte("x"), len(keyB)), 2)
assert.Equal(t, "a", commonName(t, r))
// Fixing the key leaves its modification time and size unchanged.
writeFile(t, keyFile, keyB, 2)
assert.Equal(t, "a", commonName(t, r), "retried before retryInterval elapsed")
now = now.Add(r.retryInterval)
assert.Equal(t, "b", commonName(t, r))
}
// TestGetCertificateFailureWarnings verifies that a persistent failure is attempted, and logged,
// at most once per retryInterval.
func TestGetCertificateFailureWarnings(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
var logs bytes.Buffer
r, err := NewReloader(certFile, keyFile, zerolog.New(&logs))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
warnings := func() int { return strings.Count(logs.String(), "Failed to reload X509 KeyPair") }
require.NoError(t, os.Remove(certFile))
for range 3 {
assert.Equal(t, "a", commonName(t, r))
}
assert.Equal(t, 1, warnings())
now = now.Add(r.retryInterval)
assert.Equal(t, "a", commonName(t, r))
assert.Equal(t, 2, warnings())
}
// TestGetCertificateSymlinkSwap mirrors how the kubelet updates a mounted Secret: the files are
// symlinks through a ..data symlink, which is atomically repointed at a new directory.
func TestGetCertificateSymlinkSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows cannot rename over an existing directory symlink")
}
dir := t.TempDir()
writePairFiles(t, filepath.Join(dir, "v1", "cert.pem"), filepath.Join(dir, "v1", "key.pem"), "a", 1)
require.NoError(t, os.Symlink("v1", filepath.Join(dir, "..data")))
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
require.NoError(t, os.Symlink(filepath.Join("..data", "cert.pem"), certFile))
require.NoError(t, os.Symlink(filepath.Join("..data", "key.pem"), keyFile))
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, filepath.Join(dir, "v2", "cert.pem"), filepath.Join(dir, "v2", "key.pem"), "b", 2)
require.NoError(t, os.Symlink("v2", filepath.Join(dir, "..data_tmp")))
require.NoError(t, os.Rename(filepath.Join(dir, "..data_tmp"), filepath.Join(dir, "..data")))
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateConcurrent(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
done := make(chan struct{})
for range 8 {
go func() {
defer func() { done <- struct{}{} }()
for range 50 {
cert, err := r.GetCertificate(nil)
assert.NoError(t, err)
assert.NotNil(t, cert)
}
}()
}
writePairFiles(t, certFile, keyFile, "b", 2)
for range 8 {
<-done
}
assert.Equal(t, "b", commonName(t, r))
}
func testLogger(t *testing.T) zerolog.Logger {
t.Helper()
return zerolog.New(zerolog.NewTestWriter(t))
}
// commonName returns the subject common name of the certificate r currently serves.
func commonName(t *testing.T, r *Reloader) string {
t.Helper()
cert, err := r.GetCertificate(&tls.ClientHelloInfo{})
require.NoError(t, err)
require.NotNil(t, cert)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
return leaf.Subject.CommonName
}
// writePairFiles writes a new key pair for commonName, stamping both files with a modification
// time derived from version so each version is distinguishable regardless of timestamp
// granularity.
func writePairFiles(t *testing.T, certFile, keyFile, commonName string, version int) {
t.Helper()
certPEM, keyPEM := generatePair(t, commonName)
writeFile(t, certFile, certPEM, version)
writeFile(t, keyFile, keyPEM, version)
}
func writeFile(t *testing.T, name string, data []byte, version int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(name), 0o700))
require.NoError(t, os.WriteFile(name, data, 0o600))
mtime := time.Date(2026, 1, 1, 0, 0, version, 0, time.UTC)
require.NoError(t, os.Chtimes(name, mtime, mtime))
}
// generatePair returns PEM encoded certificate and private key for a self-signed certificate.
func generatePair(t *testing.T, commonName string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
)
// Context is passed into every request handler function
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -86,13 +85,13 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
}
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
func spaTemplateHandler(tmpl *template.Template, basePath string,
webConfig config.Web) http.Handler {
func spaTemplateHandler(tmpl *template.Template, basePath string) http.Handler {
tmplData := struct {
BasePath string
}{
BasePath: basePath,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// ensure we do now allow click jacking
w.Header().Set("X-Frame-Options", "SameOrigin")
+1 -1
View File
@@ -21,6 +21,6 @@ func TextToHTML(text string) string {
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.Replace(url, "&amp;", "&", -1)
unescaped := strings.ReplaceAll(url, "&amp;", "&")
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}
+50 -37
View File
@@ -15,10 +15,10 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -31,10 +31,9 @@ var (
// incoming requests to the correct handler function
Router = mux.NewRouter()
rootConfig *config.Root
server *http.Server
listener net.Listener
globalShutdown chan bool
rootConfig *config.Root
server *http.Server
listener net.Listener
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
@@ -45,15 +44,15 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method.
func Initialize(
conf *config.Root,
shutdownChan chan bool,
mm message.Manager,
mh *msghub.Hub) {
// Server defines an instance of the Web server.
type Server struct {
// TODO Migrate global vars here.
notify chan error // Notify on fatal error.
}
// NewServer sets up things for unit tests or the Start() method.
func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers.
msgHub = mh
@@ -66,6 +65,9 @@ func Initialize(
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
Msg("Base path configured")
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
// Redirect prefix when missing trailing slash.
Router.Path(prefix("")).Handler(http.RedirectHandler(redirectBase, http.StatusFound))
}
// Dynamic paths.
@@ -107,7 +109,7 @@ func Initialize(
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
spaTemplateHandler(indexTmpl, prefix("/")))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
@@ -118,10 +120,21 @@ func Initialize(
http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path")
s := &Server{
notify: make(chan error, 1),
}
return s
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
func (s *Server) Start(ctx context.Context, readyFunc func()) {
var (
err error
listenCfg net.ListenConfig
)
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: requestLoggingWrapper(Router),
@@ -132,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 {
@@ -176,26 +192,23 @@ func appConfigCookie(webConfig config.Web) *http.Cookie {
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
func (s *Server) serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case _ = <-ctx.Done():
case <-ctx.Done():
// Nop
default:
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP server failed")
emergencyShutdown()
s.notify <- err
close(s.notify)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
// Notify allows the running Web server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
}
+64 -19
View File
@@ -2,16 +2,20 @@ package file
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -21,7 +25,7 @@ const indexFileName = "index.gob"
var (
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
// because we only want one regardless of the number of DataStore objects
// because we only want one regardless of the number of DataStore objects.
countChannel = make(chan int, 10)
)
@@ -30,7 +34,7 @@ func init() {
go countGenerator(countChannel)
}
// Populates the channel with numbers
// Populates the channel with numbers.
func countGenerator(c chan int) {
for i := 0; true; i = (i + 1) % 10000 {
c <- i
@@ -38,29 +42,33 @@ func countGenerator(c chan int) {
}
// Store implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
// hiearchy. It provides access to Mailbox objects.
type Store struct {
hashLock storage.HashLock
path string
mailPath string
messageCap int
bufReaderPool sync.Pool
extHost *extension.Host
}
// New creates a new DataStore object using the specified path
func New(cfg config.Storage) (storage.Store, error) {
// New creates a new DataStore object using the specified path.
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
path := cfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("'path' parameter not specified")
return nil, errors.New("'path' parameter not specified")
}
mailPath := filepath.Join(path, "mail")
mailPath := getMailPath(path)
if _, err := os.Stat(mailPath); err != nil {
// Mail datastore does not yet exist
// Mail datastore does not yet exist, create it.
if err = os.MkdirAll(mailPath, 0770); err != nil {
log.Error().Str("module", "storage").Str("path", mailPath).Err(err).
Msg("Error creating dir")
return nil, err
}
}
return &Store{
path: path,
mailPath: mailPath,
@@ -70,6 +78,7 @@ func New(cfg config.Storage) (storage.Store, error) {
return bufio.NewReader(nil)
},
},
extHost: extHost,
}, nil
}
@@ -82,16 +91,19 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
if err != nil {
return "", err
}
// Create a new message.
fm, err := mb.newMessage()
if err != nil {
return "", err
}
// Ensure mailbox directory exists.
if err := mb.createDir(); err != nil {
return "", err
}
// Write the message content
// Write the message content.
file, err := os.Create(fm.rawPath())
if err != nil {
return "", err
@@ -99,23 +111,24 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
w := bufio.NewWriter(file)
size, err := io.Copy(w, r)
if err != nil {
// Try to remove the file
// Try to remove the file.
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
_ = r.Close()
if err := w.Flush(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
if err := file.Close(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = os.Remove(fm.rawPath())
return "", err
}
// Update the index.
fm.Fdate = m.Date()
fm.Ffrom = m.From()
@@ -124,10 +137,11 @@ func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
fm.Fsubject = m.Subject()
mb.messages = append(mb.messages, fm)
if err := mb.writeIndex(); err != nil {
// Try to remove the file
// Try to remove the file.
_ = os.Remove(fm.rawPath())
return "", err
}
return fm.Fid, nil
}
@@ -152,11 +166,13 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
if m.Fid == id {
if m.Fseen {
@@ -167,6 +183,7 @@ func (fs *Store) MarkSeen(mailbox, id string) error {
break
}
}
return mb.writeIndex()
}
@@ -183,6 +200,17 @@ func (fs *Store) PurgeMessages(mailbox string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
// Emit delete events.
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
fs.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return mb.purge()
}
@@ -193,19 +221,22 @@ func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
if err != nil {
return err
}
// Loop over level 1 directories
// Loop over level 1 directories.
for _, name1 := range names1 {
names2, err := readDirNames(fs.mailPath, name1)
if err != nil {
return err
}
// Loop over level 2 directories
// Loop over level 2 directories.
for _, name2 := range names2 {
names3, err := readDirNames(fs.mailPath, name1, name2)
if err != nil {
return err
}
// Loop over mailboxes
// Loop over mailboxes.
for _, name3 := range names3 {
mb := fs.mboxFromHash(name3)
mb.RLock()
@@ -230,6 +261,7 @@ func (fs *Store) mbox(mailbox string) *mbox {
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
@@ -246,6 +278,7 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
@@ -280,11 +313,23 @@ func generateID(date time.Time) string {
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
}
// getMailPath converts a filestore `path` parameter into the effective mail store path.
// Within the path, '$' is replaced with ':' to support Windows drive letters with our
// env->config map syntax.
func getMailPath(base string) string {
path := strings.ReplaceAll(base, "$", ":")
return filepath.Join(path, "mail")
}
// readDirNames returns a slice of filenames in the specified directory or an error.
func readDirNames(elem ...string) ([]string, error) {
f, err := os.Open(filepath.Join(elem...))
if err != nil {
return nil, err
}
defer func() {
_ = f.Close()
}()
return f.Readdirnames(0)
}
+57 -62
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/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
ds, _ := setupDataStore(conf)
destroy := func() {
teardownDataStore(ds)
}
return ds, destroy, nil
})
test.StoreSuite(t,
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
ds, _ := setupDataStore(conf, extHost)
destroy := func() {
teardownDataStore(ds)
}
return ds, destroy, nil
})
}
// Test filestore initialization.
func TestFSNew(t *testing.T) {
// Should fail if no path specified.
ds, err := New(config.Storage{}, extension.NewHost())
require.ErrorContains(t, err, "parameter not specified")
assert.Nil(t, ds)
}
func TestFSGetMailPath(t *testing.T) {
// Path should have `mail` dir appended.
got := getMailPath(`one`)
assert.Regexp(t, "^one.mail$", got, "Expected one/mail or similar")
// Path should convert `$` to `:`.
got = getMailPath(`C$\inbucket`)
assert.Regexp(t, "^C:.inbucket.mail$", got, "Expected C:\\inbucket\\mail or similar")
}
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
root := ds.path
@@ -52,7 +68,7 @@ func TestFSDirStructure(t *testing.T) {
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
id1, _ := test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
@@ -69,7 +85,7 @@ func TestFSDirStructure(t *testing.T) {
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
@@ -79,7 +95,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err := ds.RemoveMessage(mbName, id1)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
@@ -89,7 +105,7 @@ func TestFSDirStructure(t *testing.T) {
// Delete message
err = ds.RemoveMessage(mbName, id2)
assert.Nil(t, err)
require.NoError(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
@@ -111,7 +127,7 @@ func TestFSDirStructure(t *testing.T) {
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
mbName := "fred"
@@ -120,21 +136,21 @@ func TestFSMissing(t *testing.T) {
for i, subj := range subjects {
// Add a message
id, _ := deliverMessage(ds, mbName, subj, time.Now())
id, _ := test.DeliverToStore(t, ds, mbName, subj, time.Now())
sentIds[i] = id
}
// Delete a message file without removing it from index
msg, err := ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
fmsg := msg.(*Message)
_ = os.Remove(fmsg.rawPath())
msg, err = ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
require.NoError(t, err)
// Try to read parts of message
_, err = msg.Source()
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -146,7 +162,7 @@ func TestFSMissing(t *testing.T) {
// Test Get the latest message
func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
ds, logbuf := setupDataStore(config.Storage{}, extension.NewHost())
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
@@ -155,29 +171,29 @@ func TestGetLatestMessage(t *testing.T) {
// Test empty mailbox
msg, err := ds.GetMessage(mbName, "latest")
assert.Nil(t, msg)
assert.Error(t, err)
require.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
test.DeliverToStore(t, ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
id2, _ := test.DeliverToStore(t, ds, mbName, "test 2", time.Now())
// Test get the latest message
msg, err = ds.GetMessage(mbName, "latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
require.NoError(t, err)
assert.Equal(t, id2, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
id3, _ := test.DeliverToStore(t, ds, mbName, "test 3", time.Now())
msg, err = ds.GetMessage(mbName, "latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
require.NoError(t, err)
assert.Equal(t, id3, msg.ID(), "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = ds.GetMessage(mbName, "wrongid")
assert.Error(t, err)
require.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
@@ -188,47 +204,26 @@ func TestGetLatestMessage(t *testing.T) {
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
func setupDataStore(cfg config.Storage, extHost *extension.Host) (*Store, *bytes.Buffer) {
path, err := os.MkdirTemp("", "inbucket")
if err != nil {
panic(err)
}
// Capture log output.
buf := new(bytes.Buffer)
log.SetOutput(buf)
if cfg.Params == nil {
cfg.Params = make(map[string]string)
}
cfg.Params["path"] = path
s, err := New(cfg)
s, err := New(cfg, extHost)
if err != nil {
panic(err)
}
return s.(*Store), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
// Build message for delivery
meta := message.Metadata{
Mailbox: mbName,
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
}
id, err := ds.AddMessage(delivery)
if err != nil {
panic(err)
}
return id, int64(len(testMsg))
return s.(*Store), buf
}
func teardownDataStore(ds *Store) {
+10 -3
View File
@@ -9,7 +9,8 @@ import (
"path/filepath"
"sync"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog/log"
)
@@ -72,6 +73,10 @@ func (mb *mbox) removeMessage(id string) error {
msg = m
// Slice around message we are deleting
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
// Emit deleted event.
mb.store.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(msg))
break
}
}
@@ -107,6 +112,8 @@ func (mb *mbox) readIndex() error {
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
Msg("Index does not yet exist")
mb.indexLoaded = true
//lint:ignore nilerr missing mailboxes are considered empty.
return nil
}
file, err := os.Open(mb.indexPath)
@@ -125,7 +132,7 @@ func (mb *mbox) readIndex() error {
dec := gob.NewDecoder(br)
name := ""
if err = dec.Decode(&name); err != nil {
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
mb.name = name
for {
@@ -135,7 +142,7 @@ func (mb *mbox) readIndex() error {
if err == io.EOF {
break
}
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
return fmt.Errorf("corrupt mailbox %q: %v", mb.indexPath, err)
}
msg.mailbox = mb
mb.messages = append(mb.messages, msg)
+1 -1
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 {
+2 -2
View File
@@ -3,7 +3,7 @@ package storage_test
import (
"testing"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
func TestHashLock(t *testing.T) {
@@ -36,7 +36,7 @@ func TestHashLock(t *testing.T) {
t.Run(ts, func(t *testing.T) {
l := hl.Get(ts)
if l == nil {
t.Errorf("Expeced non-nil lock for hex string %q", ts)
t.Errorf("Expected non-nil lock for hex string %q", ts)
}
})
}
+2 -3
View File
@@ -4,11 +4,10 @@ import (
"bytes"
"container/list"
"io"
"io/ioutil"
"net/mail"
"time"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
// Message is a memory store message.
@@ -47,7 +46,7 @@ func (m *Message) Subject() string { return m.subject }
// Source returns a reader for the message source.
func (m *Message) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
return io.NopCloser(bytes.NewReader(m.source)), nil
}
// Size returns the message size in bytes.

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