mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-11-03 16:12:36 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742249691d | |||
| 6d3d2a99c0 | |||
| 26df522a5a | |||
| 8075e8d2a3 | |||
| 9876bb2bd9 | |||
| 94472ab496 | |||
| 47239c08eb | |||
| 9aaad6b950 | |||
| a85a4ab996 | |||
| 8bad098be9 | |||
| 68fc60fe9f | |||
| fc8a78b4fa | |||
| 9663b06225 | |||
| f54715ee57 | |||
| b67cea3c0a | |||
| 3f6afaad10 | |||
| ce9946a682 | |||
| 99790dbd9f | |||
| 151fcd4e70 | |||
| 2221fb4919 | |||
| 19691e9d97 | |||
| 74cde72786 | |||
| e55882dc5a | |||
| e84b0d53d6 | |||
| c41af3f5e3 | |||
| e56c6d5f83 | |||
| 5fec537c16 | |||
| 6bfeb91955 | |||
| 53f4f0091e | |||
| be7d29c5d5 | |||
| 6eff554469 | |||
| 7413d06616 | |||
| 22c276ea1a | |||
| dd22202aea | |||
| 32b0ff1ac6 | |||
| e0824eb0aa | |||
| f210b4c47c | |||
| 2ea0639509 | |||
| 4399d02f0b | |||
| 25007f4506 | |||
| fe0e3a00e1 | |||
| 577a329240 | |||
| c3a8eb8e3b | |||
| 273c6a5dbd | |||
| f799e3debf | |||
| 8a1a01660c | |||
| d1f2ae7946 | |||
| 8339cb5378 | |||
| cf92969719 | |||
| 9a2b0f934a | |||
| b99cf9b6dc | |||
| f6d00dfcb2 | |||
| 440fddfe46 | |||
| 9904399d24 | |||
| 4c8c8e7744 |
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.25
|
||||
+30
-26
@@ -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
@@ -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*
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user