1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-11-03 16:12:36 +00:00

Compare commits

..

55 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
50 changed files with 4717 additions and 2196 deletions
+10 -14
View File
@@ -9,19 +9,15 @@ on:
jobs:
linux-go-build:
runs-on: ubuntu-latest
name: Linux Go ${{ matrix.go }} build
strategy:
matrix:
go:
- '1.23'
name: Linux Go build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
go-version-file: '.go-version'
check-latest: true
- name: Build and test
run: |
@@ -31,20 +27,20 @@ jobs:
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
flag-name: Linux-Go-${{ matrix.go }}
flag-name: Linux-Go
parallel: true
windows-go-build:
runs-on: windows-latest
name: Windows Go build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: '1.23'
go-version-file: '.go-version'
- name: Build
run: go build ./...
- name: Test
@@ -60,11 +56,11 @@ jobs:
runs-on: ubuntu-latest
name: UI Build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'
cache: 'yarn'
+7 -7
View File
@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
inbucket/inbucket
@@ -39,30 +39,30 @@ jobs:
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ env.REGISTRY_PUSH == 'true' }}
uses: docker/login-action@v3
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@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64, linux/arm/v7
+4 -4
View File
@@ -7,11 +7,11 @@ jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.23'
go-version-file: '.go-version'
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: latest
+6 -6
View File
@@ -13,18 +13,18 @@ jobs:
name: 'Go Releaser'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: '1.23'
go-version-file: '.go-version'
check-latest: true
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20.x'
cache: 'yarn'
@@ -37,7 +37,7 @@ jobs:
working-directory: ./ui
- name: Test build release
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
distribution: goreleaser
@@ -45,7 +45,7 @@ jobs:
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
if: "startsWith(github.ref, 'refs/tags/v')"
with:
distribution: goreleaser
+1
View File
@@ -0,0 +1 @@
1.25
+30 -26
View File
@@ -1,5 +1,4 @@
run:
timeout: 5m
version: "2"
linters:
enable:
- asasalint
@@ -8,31 +7,19 @@ linters:
- bodyclose
- containedctx
- contextcheck
- copyloopvar
- decorder
# - dupl
# - dupword
- durationcheck
- errchkjson
- errname
# - errorlint
# - exhaustive
- exportloopref
# - forcetypeassert
- ginkgolinter
- gocheckcompilerdirectives
# - gochecknoinits
- gochecksumtype
- gocritic
# - godot
# - goerr113
- gofmt
# - gofumpt
- goheader
- goimports
- gomoddirectives
- gomodguard
- goprintffuncname
# - gosec
- gosmopolitan
- grouper
- importas
@@ -44,8 +31,6 @@ linters:
- misspell
- musttag
- nilerr
# - nilnil
# - nlreturn
- noctx
- nolintlint
- nosprintfhostport
@@ -55,24 +40,43 @@ linters:
- promlinter
- protogetter
- reassign
# - revive
- rowserrcheck
- sloglint
- stylecheck
- staticcheck
- tagliatelle
- tenv
- testableexamples
- testifylint
- thelper
- tparallel
# - unconvert
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- zerologlint
linters-settings:
tagliatelle:
case:
rules:
json: kebab
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$
+8 -2
View File
@@ -27,6 +27,9 @@ builds:
- arm64
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- id: inbucket-client
@@ -44,16 +47,19 @@ builds:
- arm64
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
main: ./cmd/client
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archives:
- id: tarball
format: tar.gz
formats: tar.gz
wrap_in_directory: true
format_overrides:
- goos: windows
format: zip
formats: zip
files:
- LICENSE*
- README*
+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
+37 -2
View File
@@ -7,6 +7,38 @@ 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
@@ -370,7 +402,10 @@ No change from beta1.
specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta3...main
[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
@@ -409,7 +444,7 @@ No change from beta1.
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
3. Run tests
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
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
+3 -3
View File
@@ -2,7 +2,7 @@
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 as frontend
FROM --platform=linux/amd64 node:20 AS frontend
RUN npm install -g node-gyp
WORKDIR /build
COPY . .
@@ -12,7 +12,7 @@ RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.23-alpine3.20 as backend
FROM golang:1.25-alpine3.22 AS backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
@@ -23,7 +23,7 @@ RUN go build -o inbucket \
-v ./cmd/inbucket
### Run in minimal image
FROM alpine:3.20
FROM alpine:3.22
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
+7
View File
@@ -30,6 +30,13 @@ Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
tracks our tagged releases, and `edge` tracks our potentially unstable
`main` branch.
Start the docker image by running:
```
docker run -d --name inbucket -p 9000:9000 -p 2500:2500 -p 1100:1100 inbucket/inbucket
```
Then point your browser to [localhost:9000](http://localhost:9000/)
## Building from Source
+1 -1
View File
@@ -37,7 +37,7 @@ func init() {
startTime.Set(time.Now().UnixNano() / 1000000)
// Goroutine count for status page.
expvar.Publish("goroutines", expvar.Func(func() interface{} {
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
+8
View File
@@ -243,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`
+14 -14
View File
@@ -28,41 +28,41 @@ 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
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
swaks $* --data @mime-inline.raw
+22 -21
View File
@@ -1,8 +1,6 @@
module github.com/inbucket/inbucket/v3
go 1.21
toolchain go1.21.4
go 1.25.0
require (
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
@@ -12,35 +10,38 @@ require (
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.0.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/microcosm-cc/bluemonday v1.0.27
github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.9.0
github.com/yuin/gopher-lua v1.1.1
golang.org/x/net v0.29.0
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
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/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/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/tablewriter v0.0.5 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // 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
)
+42 -56
View File
@@ -2,17 +2,22 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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=
@@ -25,68 +30,49 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime/v2 v2.0.0 h1:I39PYf0peLGroKq+uX2yGB1ExH/78HcRJy4VmERQAVk=
github.com/jhillyerd/enmime/v2 v2.0.0/go.mod h1:wQkz7BochDzSukAz5ajAQaXOB7pEg5Vh5QWs7m1uAPw=
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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
+1 -1
View File
@@ -38,7 +38,7 @@ func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
// Pre-load, parse, and compile script.
if fi, err := os.Stat(scriptPath); err != nil {
logger.Info().Msg("Script file not found")
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)
+3 -2
View File
@@ -55,6 +55,7 @@ func (s *StoreManager) Deliver(
// Parse envelope headers.
header, err := enmime.DecodeHeaders(source)
if err != nil {
logger.Error().Err(err).Msg("Failed to parse message headers")
return err
}
@@ -112,7 +113,7 @@ func (s *StoreManager) Deliver(
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{
@@ -124,7 +125,7 @@ func (s *StoreManager) Deliver(
Subject: inbound.Subject,
Size: inbound.Size,
},
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
Reader: io.MultiReader(strings.NewReader(returnPath), strings.NewReader(recvd), bytes.NewReader(source)),
}
id, err := s.Store.AddMessage(delivery)
if err != nil {
+32
View File
@@ -501,6 +501,38 @@ func TestMailboxForAddress(t *testing.T) {
assert.Equal(t, addr, got, "FullNaming mode should return a full address for mailbox")
}
func TestReturnPath(t *testing.T) {
sm, _ := testStoreManager()
recvdHeader := "Received: xyz\n"
msgSource := `From: from@example.com
To: u1@example.com
Subject: return path
test email`
// Deliver message.
origin, _ := sm.AddrPolicy.ParseOrigin("821from@example.com")
recipient, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
err := sm.Deliver(origin, []*policy.Recipient{recipient}, recvdHeader, []byte(msgSource))
require.NoError(t, err)
// Find message ID.
msgs, err := sm.GetMetadata("u1@example.com")
require.NoError(t, err, "Failed to read mailbox")
require.Len(t, msgs, 1, "Unexpected mailbox len")
id := msgs[0].ID
// Read back and verify source.
r, err := sm.SourceReader("u1@example.com", id)
require.NoError(t, err, "SourceReader must succeed")
gotBytes, err := io.ReadAll(r)
require.NoError(t, err, "Failed to read source")
got := string(gotBytes)
assert.Contains(t, got, "Return-Path: <821from@example.com>\r\n", "Source should contain return-path")
}
// Returns an empty StoreManager and extension Host pair, configured for testing.
func testStoreManager() (*message.StoreManager, *extension.Host) {
extHost := extension.NewHost()
+10 -10
View File
@@ -81,7 +81,7 @@ func TestHubZeroLen(t *testing.T) {
hub := New(0, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ {
for range 100 {
hub.Dispatch(m)
}
// Ensures Hub doesn't panic
@@ -93,7 +93,7 @@ func TestHubZeroListeners(t *testing.T) {
hub := New(5, extension.NewHost())
go hub.Start(ctx)
m := event.MessageMetadata{}
for i := 0; i < 100; i++ {
for range 100 {
hub.Dispatch(m)
}
// Ensures Hub doesn't panic
@@ -178,7 +178,7 @@ func TestHubHistoryReplay(t *testing.T) {
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
@@ -203,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 {
@@ -222,7 +222,7 @@ func TestHubHistoryDelete(t *testing.T) {
// Broadcast 3 messages with no listeners
msgs := make([]event.MessageMetadata, 3)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "hub",
ID: strconv.Itoa(i),
@@ -253,7 +253,7 @@ func TestHubHistoryDelete(t *testing.T) {
}
want := []string{"subj 0", "subj 2"}
for i := 0; i < len(want); i++ {
for i := range want {
got := l2.messages[i].Subject
if got != want[i] {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
@@ -271,7 +271,7 @@ func TestHubHistoryReplayWrap(t *testing.T) {
// Broadcast more messages than the hub can hold
msgs := make([]event.MessageMetadata, 20)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Subject: fmt.Sprintf("subj %v", i),
}
@@ -296,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 {
@@ -326,7 +326,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
// Broadcast more messages than the hub can hold.
msgs := make([]event.MessageMetadata, 10)
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "first",
ID: strconv.Itoa(i),
@@ -343,7 +343,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
hub.Delete("first", "7")
// Broadcast another set of messages.
for i := 0; i < len(msgs); i++ {
for i := range msgs {
msgs[i] = event.MessageMetadata{
Mailbox: "second",
ID: strconv.Itoa(i),
+7 -2
View File
@@ -75,6 +75,11 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
// not To headers.
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
if address == "" {
return &Origin{
addrPolicy: a,
}, nil
}
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
@@ -269,7 +274,7 @@ func parseEmailAddress(address string) (local string, domain string, err error)
inCharQuote := false
inStringQuote := false
LOOP:
for i := 0; i < len(address); i++ {
for i := range len(address) {
c := address[i]
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
@@ -375,7 +380,7 @@ func parseMailboxName(localPart string) (result string, err error) {
}
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':
+248
View File
@@ -484,6 +484,254 @@ 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) {
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -18,7 +19,7 @@ import (
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")
@@ -122,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")
@@ -244,7 +245,7 @@ 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)
+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")
}
+12 -195
View File
@@ -2,220 +2,37 @@ package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/rs/zerolog/log"
)
const (
// Time allowed to write a message to the peer.
writeWaitV1 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriodV1 = (pongWaitV1 * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWaitV1 = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSizeV1 = 512
)
// options for gorilla connection upgrader
var upgraderV1 = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListenerV1 handles messages from the msghub
type msgListenerV1 struct {
hub *msghub.Hub // Global message hub
c chan event.MessageMetadata // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes
}
// newMsgListenerV1 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListenerV1 {
ml := &msgListenerV1{
hub: hub,
c: make(chan event.MessageMetadata, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message.
func (ml *msgListenerV1) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match the watched mailbox name.
return nil
}
ml.c <- msg
return nil
}
// Delete handles a deleted message.
func (ml *msgListenerV1) Delete(mailbox string, id string) error {
// Deletes are ignored in socketv1 API.
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListenerV1) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close()
conn.SetReadLimit(maxMessageSizeV1)
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to setup read deadline")
}
conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
}
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
slog.Warn().Err(err).Msg("Socket error")
} else {
slog.Debug().Msg("Closing socket")
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListenerV1) WSWriter(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
ticker := time.NewTicker(pingPeriodV1)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case msg, ok := <-ml.c:
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
}
if !ok {
// msgListener closed, exit
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if conn.WriteJSON(metadataToHeader(&msg)) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV1)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
}
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
slog.Debug().Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListenerV1) 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 := upgraderV1.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 := newMsgListenerV1(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 := upgraderV1.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 := newMsgListenerV1(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
func metadataToHeader(msg *event.MessageMetadata) *model.JSONMessageHeaderV1 {
return &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV1)
}
+21 -197
View File
@@ -2,224 +2,48 @@ package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/rs/zerolog/log"
)
const (
// Time allowed to write a message to the peer.
writeWaitV2 = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriodV2 = (pongWaitV2 * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWaitV2 = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSizeV2 = 512
)
// options for gorilla connection upgrader
var upgraderV2 = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListenerV2 handles messages from the msghub
type msgListenerV2 struct {
hub *msghub.Hub // Global message hub.
c chan *model.JSONMonitorEventV2 // Queue of incoming events.
mailbox string // Name of mailbox to monitor, "" == all mailboxes.
}
// newMsgListenerV2 creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListenerV2 {
ml := &msgListenerV2{
hub: hub,
c: make(chan *model.JSONMonitorEventV2, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message.
func (ml *msgListenerV2) Receive(msg event.MessageMetadata) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match the watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-stored",
Header: metadataToHeader(&msg),
}
return nil
}
// Delete handles a deleted message.
func (ml *msgListenerV2) Delete(mailbox string, id string) error {
if ml.mailbox != "" && ml.mailbox != mailbox {
// Did not match watched mailbox name.
return nil
}
// Enqueue for websocket.
ml.c <- &model.JSONMonitorEventV2{
Variant: "message-deleted",
Identifier: &model.JSONMessageIDV2{
Mailbox: mailbox,
ID: id,
// 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),
}
},
}
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListenerV2) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
defer ml.Close()
conn.SetReadLimit(maxMessageSizeV2)
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to setup read deadline")
}
conn.SetPongHandler(func(string) error {
slog.Debug().Msg("Got pong")
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
}
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
slog.Warn().Err(err).Msg("Socket error")
} else {
slog.Debug().Msg("Closing socket")
func(mailbox string, id string) *model.JSONMonitorEventV2 {
return &model.JSONMonitorEventV2{
Variant: "message-deleted",
Identifier: &model.JSONMessageIDV2{
Mailbox: mailbox,
ID: id,
},
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListenerV2) WSWriter(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
ticker := time.NewTicker(pingPeriodV2)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case event, ok := <-ml.c:
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
}
if !ok {
// msgListener closed, exit
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if conn.WriteJSON(event) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
}
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
slog.Debug().Msg("Sent ping")
}
}
}
// Close removes the listener registration
func (ml *msgListenerV2) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
})
}
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
// the client of all messages received.
func MonitorAllMessagesV2(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
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) (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 := upgraderV2.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListenerV2(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV2)
}
+2 -2
View File
@@ -51,7 +51,7 @@ func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error)
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)
@@ -63,7 +63,7 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
},
}
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.NewServer(cfg, mm, &msghub.Hub{})
web.NewServer(cfg, mm, hub)
return buf
}
+111 -176
View File
@@ -2,6 +2,7 @@ package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
@@ -59,19 +60,20 @@ var commands = map[string]bool{
// 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
@@ -101,16 +103,17 @@ 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)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
tlsState = new(tls.ConnectionState)
*tlsState = tlsConn.ConnectionState()
conn = tlsConn
}
@@ -126,6 +129,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}()
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))
@@ -143,7 +147,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
if s.tlsConfig != nil && ssn.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
ssn.send(".")
@@ -165,7 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
ssn.authorizationHandler(ctx, cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
@@ -206,7 +210,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
// AUTHORIZATION state
func (s *Session) authorizationHandler(cmd string, args []string) {
func (s *Session) authorizationHandler(ctx context.Context, cmd string, args []string) {
switch cmd {
case "QUIT":
s.send("+OK Goodnight and good luck")
@@ -214,7 +218,7 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
s.enterState(QUIT)
case "STLS":
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
if !s.config.TLSEnabled || s.config.ForceTLS {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
s.send("-ERR TLS unavailable on the server")
@@ -229,11 +233,16 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
tlsCtx, cancel := context.WithTimeout(ctx, s.config.Timeout)
defer cancel()
s.send("+OK Begin TLS Negotiation")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
s.ooSeq(cmd)
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)
@@ -290,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] {
@@ -401,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")
@@ -454,8 +364,13 @@ 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()
@@ -472,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")
@@ -528,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)
}
+75 -2
View File
@@ -21,6 +21,7 @@ import (
"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) {
@@ -245,6 +246,33 @@ func TestDupStartTLS(t *testing.T) {
}
}
// 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)
@@ -288,6 +316,51 @@ func TestForceTLS(t *testing.T) {
}
}
// 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
@@ -302,7 +375,7 @@ func setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Se
cfg := config.POP3{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
Timeout: 5,
Timeout: 5 * time.Second,
Debug: true,
ForceTLS: forceTLS,
}
@@ -344,7 +417,7 @@ func setupPOPSession(t *testing.T, server *Server) net.Conn {
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
go server.startSession(context.Background(), sessionNum, &mockConn{serverConn})
return clientConn
}
+4 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"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"
)
@@ -21,7 +22,6 @@ type Server struct {
wg *sync.WaitGroup // Waitgroup tracking sessions.
notify chan error // Notify on fatal error.
tlsConfig *tls.Config // TLS encryption configuration.
tlsState *tls.ConnectionState
}
// NewServer creates a new, unstarted, POP3 server.
@@ -29,14 +29,13 @@ func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{}
if pop3Config.TLSEnabled {
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
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
@@ -120,7 +119,7 @@ func (s *Server) serve(ctx context.Context) {
} else {
tempDelay = 0
s.wg.Add(1)
go s.startSession(sid, conn)
go s.startSession(ctx, sid, conn)
}
}
}
+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)
}
+4 -16
View File
@@ -300,7 +300,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && !s.Server.config.ForceTLS && s.Server.tlsConfig != nil && s.tlsState == nil {
if s.config.TLSEnabled && !s.config.ForceTLS && s.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
@@ -338,7 +338,7 @@ func (s *Session) passwordHandler() {
func (s *Session) readyHandler(cmd string, arg string) {
switch cmd {
case "STARTTLS":
if !s.Server.config.TLSEnabled {
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")
@@ -354,7 +354,7 @@ 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)
@@ -411,18 +411,6 @@ func (s *Session) parseMailFromCmd(arg string) {
from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
// Parse from address.
_, domain, err := policy.ParseEmailAddress(from)
s.logger.Debug().Msgf("Origin domain is %v", domain)
if from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// Parse ESMTP parameters.
if m[2] != "" {
// Here the client may put BODY=8BITMIME, but Inbucket already
@@ -480,7 +468,7 @@ func (s *Session) parseMailFromCmd(arg string) {
// 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", domain)
s.logger.Warn().Msgf("Bad domain sender %s", origin.Domain)
return
}
+7 -4
View File
@@ -78,6 +78,11 @@ func TestGreetState(t *testing.T) {
}
}
// Messages sent with a null reverse-path are unusual,
// but valid. They are used for delivery status
// notifications, and also for some sorts of auto-responder
// as part of bounce storm mitigation.
// Sections 3.6.3 and 4.5.5 of RFC 5321 discuss them.
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
@@ -85,14 +90,14 @@ func TestEmptyEnvelope(t *testing.T) {
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 501},
{"MAIL FROM:<>", 250},
}
playSession(t, server, script)
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 501},
{"MAIL FROM: <>", 250},
}
playSession(t, server, script)
}
@@ -450,7 +455,6 @@ func TestBeforeMailFromAcceptedEventResponse(t *testing.T) {
}
for name, tc := range tcs {
tc := tc
t.Run(name, func(t *testing.T) {
// Reset event listener.
shouldReturn = &tc.eventRes
@@ -619,7 +623,6 @@ func TestBeforeRcptToAcceptedEventResponse(t *testing.T) {
}
for name, tc := range tcs {
tc := tc
t.Run(name, func(t *testing.T) {
// Reset event listener.
shouldReturn = &tc.eventRes
+3 -3
View File
@@ -14,6 +14,7 @@ import (
"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"
)
@@ -79,14 +80,13 @@ func NewServer(
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")
}
}
+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
}
+10 -2
View File
@@ -130,6 +130,11 @@ func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
// Start begins listening for HTTP requests
func (s *Server) Start(ctx context.Context, readyFunc func()) {
var (
err error
listenCfg net.ListenConfig
)
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: requestLoggingWrapper(Router),
@@ -140,8 +145,11 @@ func (s *Server) Start(ctx context.Context, readyFunc func()) {
// We don't use ListenAndServe because it lacks a way to close the listener
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
Msg("HTTP listening on tcp4")
var err error
listener, err = net.Listen("tcp", server.Addr)
// This context is only used while the listener is resolving our address.
listenCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
listener, err = listenCfg.Listen(listenCtx, "tcp", server.Addr)
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP failed to start TCP4 listener")
+1 -1
View File
@@ -37,7 +37,7 @@ func TestMaxSize(t *testing.T) {
for _, mailbox := range boxes {
go func(mailbox string) {
size := int64(0)
for i := 0; i < n; i++ {
for range n {
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
size += nbytes
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
}
for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id {
msg.MessageMetadata.Seen = true
msg.Seen = true
return nil
}
}
+5 -8
View File
@@ -139,7 +139,7 @@ func testMetadata(s storeSuite) {
// testContent generates some binary content and makes sure it is correctly retrieved.
func testContent(s storeSuite) {
content := make([]byte, 5000)
for i := 0; i < len(content); i++ {
for i := range content {
content[i] = byte(i % 256)
}
mailbox := "testmailbox"
@@ -379,7 +379,7 @@ func testPurge(s storeSuite) {
}
gotEvents = append(gotEvents, ev)
}
assert.Equal(s, len(subjects), len(gotEvents),
assert.Len(s, gotEvents, len(subjects),
"expected delete event for each message in mailbox")
}
@@ -388,7 +388,7 @@ func testMsgCap(s storeSuite) {
mbCap := 10
mailbox := "captain"
for i := 0; i < 20; i++ {
for i := range 20 {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
msgs, err := s.store.GetMessages(mailbox)
@@ -401,10 +401,7 @@ func testMsgCap(s storeSuite) {
}
// Check that the first (oldest) message is correct.
first := i - mbCap + 1
if first < 0 {
first = 0
}
first := max(i-mbCap+1, 0)
firstSubj := fmt.Sprintf("subject %v", first)
if firstSubj != msgs[0].Subject() {
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
@@ -415,7 +412,7 @@ func testMsgCap(s storeSuite) {
// testNoMsgCap verfies a cap of 0 is not enforced.
func testNoMsgCap(s storeSuite) {
mailbox := "captain"
for i := 0; i < 20; i++ {
for i := range 20 {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
GetAndCountMessages(s.T, s.store, mailbox, i+1)
-1
View File
@@ -31,7 +31,6 @@ func TestStoreStubMailboxAddGetVisit(t *testing.T) {
{mailbox: "box3", count: 3},
}
for _, tc := range tcs {
tc := tc
t.Run(tc.mailbox, func(t *testing.T) {
var err error
+1 -1
View File
@@ -2,7 +2,7 @@ Mailbox: recipient
From: <fromuser@inbucket.org>
To: [<recipient@inbucket.org>]
Subject: basic subject
Size: 204
Size: 242
BODY TEXT:
Basic message.
+1 -1
View File
@@ -2,7 +2,7 @@ Mailbox: recipient
From: X-äéß Y-äéß <fromuser@inbucket.org>
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
Subject: Test of ȇɲʢȯȡɪɴʛ
Size: 338
Size: 376
BODY TEXT:
Basic message.
+1 -1
View File
@@ -2,7 +2,7 @@ Mailbox: recipient
From: From User <fromuser@inbucket.org>
To: [Rec I. Pient <recipient@inbucket.org>]
Subject: basic subject
Size: 233
Size: 271
BODY TEXT:
Basic message.
+1 -1
View File
@@ -2,7 +2,7 @@ Mailbox: ip4recipient
From: <fromuser@inbucket.org>
To: [<ip4recipient@[192.168.123.123]>]
Subject: basic subject
Size: 180
Size: 218
BODY TEXT:
No-To message.
+1 -1
View File
@@ -2,7 +2,7 @@ Mailbox: ip6recipient
From: <fromuser@inbucket.org>
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
Subject: basic subject
Size: 180
Size: 218
BODY TEXT:
No-To message.
+7 -5
View File
@@ -1,4 +1,6 @@
{ pkgs ? import <nixpkgs> { } }:
{
pkgs ? import <nixpkgs> { },
}:
let
scripts = {
# Quick test script.
@@ -22,13 +24,13 @@ pkgs.mkShell {
elmPackages.elm-json
elmPackages.elm-language-server
elmPackages.elm-test
go_1_21
go_1_25
golangci-lint
golint
gopls
nodejs_20
nodePackages.node-gyp
nodePackages.yarn
nodejs_22
node-gyp
yarn
rpm
swaks
+6 -6
View File
@@ -17,14 +17,14 @@
"browserslist": "defaults",
"dependencies": {},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
"@parcel/packager-raw-url": "^2.11.0",
"@parcel/transformer-elm": "^2.11.0",
"@parcel/transformer-webmanifest": "^2.11.0",
"@fortawesome/fontawesome-free": "^7.3.1",
"@parcel/packager-raw-url": "^2.16.4",
"@parcel/transformer-elm": "^2.16.4",
"@parcel/transformer-webmanifest": "^2.16.4",
"@webcomponents/webcomponentsjs": "^2.5.0",
"opensans-npm-webfont": "^1.0.0",
"parcel": "^2.11.0",
"parcel-namer-rewrite": ">=2.10.3-rc.2"
"parcel": "^2.16.4",
"parcel-namer-rewrite": "^2.16.4"
},
"optionalDependencies": {
"elm": "^0.19.1-6"
+1438 -1393
View File
File diff suppressed because it is too large Load Diff