mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-11-05 17:12:33 +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:
|
jobs:
|
||||||
linux-go-build:
|
linux-go-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Linux Go ${{ matrix.go }} build
|
name: Linux Go build
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
go:
|
|
||||||
- '1.23'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version-file: '.go-version'
|
||||||
check-latest: true
|
check-latest: true
|
||||||
- name: Build and test
|
- name: Build and test
|
||||||
run: |
|
run: |
|
||||||
@@ -31,20 +27,20 @@ jobs:
|
|||||||
uses: shogo82148/actions-goveralls@v1
|
uses: shogo82148/actions-goveralls@v1
|
||||||
with:
|
with:
|
||||||
path-to-profile: profile.cov
|
path-to-profile: profile.cov
|
||||||
flag-name: Linux-Go-${{ matrix.go }}
|
flag-name: Linux-Go
|
||||||
parallel: true
|
parallel: true
|
||||||
|
|
||||||
windows-go-build:
|
windows-go-build:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
name: Windows Go build
|
name: Windows Go build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: '.go-version'
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build ./...
|
run: go build ./...
|
||||||
- name: Test
|
- name: Test
|
||||||
@@ -60,11 +56,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: UI Build
|
name: UI Build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
inbucket/inbucket
|
inbucket/inbucket
|
||||||
@@ -39,30 +39,30 @@ jobs:
|
|||||||
latest=auto
|
latest=auto
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
with:
|
with:
|
||||||
platforms: all
|
platforms: all
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64, linux/arm/v7
|
platforms: linux/amd64,linux/arm64, linux/arm/v7
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ jobs:
|
|||||||
golangci:
|
golangci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: '.go-version'
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ jobs:
|
|||||||
name: 'Go Releaser'
|
name: 'Go Releaser'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: '.go-version'
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
working-directory: ./ui
|
working-directory: ./ui
|
||||||
|
|
||||||
- name: Test build release
|
- name: Test build release
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
args: release --snapshot
|
args: release --snapshot
|
||||||
|
|
||||||
- name: Build and publish release
|
- name: Build and publish release
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
if: "startsWith(github.ref, 'refs/tags/v')"
|
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1.25
|
||||||
+30
-26
@@ -1,5 +1,4 @@
|
|||||||
run:
|
version: "2"
|
||||||
timeout: 5m
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
@@ -8,31 +7,19 @@ linters:
|
|||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
- containedctx
|
||||||
- contextcheck
|
- contextcheck
|
||||||
|
- copyloopvar
|
||||||
- decorder
|
- decorder
|
||||||
# - dupl
|
|
||||||
# - dupword
|
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errchkjson
|
- errchkjson
|
||||||
- errname
|
- errname
|
||||||
# - errorlint
|
|
||||||
# - exhaustive
|
|
||||||
- exportloopref
|
|
||||||
# - forcetypeassert
|
|
||||||
- ginkgolinter
|
- ginkgolinter
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
# - gochecknoinits
|
|
||||||
- gochecksumtype
|
- gochecksumtype
|
||||||
- gocritic
|
- gocritic
|
||||||
# - godot
|
|
||||||
# - goerr113
|
|
||||||
- gofmt
|
|
||||||
# - gofumpt
|
|
||||||
- goheader
|
- goheader
|
||||||
- goimports
|
|
||||||
- gomoddirectives
|
- gomoddirectives
|
||||||
- gomodguard
|
- gomodguard
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
# - gosec
|
|
||||||
- gosmopolitan
|
- gosmopolitan
|
||||||
- grouper
|
- grouper
|
||||||
- importas
|
- importas
|
||||||
@@ -44,8 +31,6 @@ linters:
|
|||||||
- misspell
|
- misspell
|
||||||
- musttag
|
- musttag
|
||||||
- nilerr
|
- nilerr
|
||||||
# - nilnil
|
|
||||||
# - nlreturn
|
|
||||||
- noctx
|
- noctx
|
||||||
- nolintlint
|
- nolintlint
|
||||||
- nosprintfhostport
|
- nosprintfhostport
|
||||||
@@ -55,24 +40,43 @@ linters:
|
|||||||
- promlinter
|
- promlinter
|
||||||
- protogetter
|
- protogetter
|
||||||
- reassign
|
- reassign
|
||||||
# - revive
|
|
||||||
- rowserrcheck
|
- rowserrcheck
|
||||||
- sloglint
|
- sloglint
|
||||||
- stylecheck
|
- staticcheck
|
||||||
- tagliatelle
|
- tagliatelle
|
||||||
- tenv
|
|
||||||
- testableexamples
|
- testableexamples
|
||||||
- testifylint
|
- testifylint
|
||||||
- thelper
|
- thelper
|
||||||
- tparallel
|
- tparallel
|
||||||
# - unconvert
|
|
||||||
- unparam
|
- unparam
|
||||||
- usestdlibvars
|
- usestdlibvars
|
||||||
|
- usetesting
|
||||||
- wastedassign
|
- wastedassign
|
||||||
- whitespace
|
- whitespace
|
||||||
- zerologlint
|
- zerologlint
|
||||||
linters-settings:
|
settings:
|
||||||
tagliatelle:
|
tagliatelle:
|
||||||
case:
|
case:
|
||||||
rules:
|
rules:
|
||||||
json: kebab
|
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
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "7"
|
- "7"
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
main: ./cmd/inbucket
|
main: ./cmd/inbucket
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
- id: inbucket-client
|
- id: inbucket-client
|
||||||
@@ -44,16 +47,19 @@ builds:
|
|||||||
- arm64
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "7"
|
- "7"
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
main: ./cmd/client
|
main: ./cmd/client
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: tarball
|
- id: tarball
|
||||||
format: tar.gz
|
formats: tar.gz
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: zip
|
||||||
files:
|
files:
|
||||||
- LICENSE*
|
- LICENSE*
|
||||||
- README*
|
- 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]
|
## [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
|
## [v3.1.0-beta3] - 2024-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -370,7 +402,10 @@ No change from beta1.
|
|||||||
specific message.
|
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-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-beta2]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...v3.1.0-beta2
|
||||||
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
|
[v3.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
|
- Add new GitHub `/compare` link
|
||||||
- Update previous tag version for *Unreleased*
|
- Update previous tag version for *Unreleased*
|
||||||
3. Run tests
|
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
|
5. Commit changes and merge release PR into main
|
||||||
6. Create new release via GitHub, use CHANGELOG release notes, tag `vX.Y.Z`
|
6. Create new release via GitHub, use CHANGELOG release notes, tag `vX.Y.Z`
|
||||||
7. Push tags and wait for
|
7. Push tags and wait for
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
### Build frontend
|
### Build frontend
|
||||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
# 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
|
RUN npm install -g node-gyp
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -12,7 +12,7 @@ RUN yarn install --frozen-lockfile --non-interactive
|
|||||||
RUN yarn run build
|
RUN yarn run build
|
||||||
|
|
||||||
### Build backend
|
### 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
|
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -23,7 +23,7 @@ RUN go build -o inbucket \
|
|||||||
-v ./cmd/inbucket
|
-v ./cmd/inbucket
|
||||||
|
|
||||||
### Run in minimal image
|
### Run in minimal image
|
||||||
FROM alpine:3.20
|
FROM alpine:3.22
|
||||||
RUN apk --no-cache add tzdata
|
RUN apk --no-cache add tzdata
|
||||||
WORKDIR /opt/inbucket
|
WORKDIR /opt/inbucket
|
||||||
RUN mkdir bin defaults ui
|
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
|
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||||
`main` branch.
|
`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
|
## Building from Source
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func init() {
|
|||||||
startTime.Set(time.Now().UnixNano() / 1000000)
|
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||||
|
|
||||||
// Goroutine count for status page.
|
// Goroutine count for status page.
|
||||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
expvar.Publish("goroutines", expvar.Func(func() any {
|
||||||
return runtime.NumGoroutine()
|
return runtime.NumGoroutine()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,14 @@ to the public internet.
|
|||||||
|
|
||||||
Enable the STARTTLS option for opportunistic TLS support
|
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`
|
- Default: `false`
|
||||||
- Values: `true` or `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"
|
export SWAKS_OPT_to="$to@inbucket.local"
|
||||||
|
|
||||||
# Basic test
|
# Basic test
|
||||||
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
|
swaks $* --h-Subject: "Swaks Plain Text" --body @text.txt
|
||||||
|
|
||||||
# Multi-recipient test
|
# Multi-recipient test
|
||||||
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
|
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
|
||||||
--body text.txt
|
--body @text.txt
|
||||||
|
|
||||||
# HTML test
|
# HTML test
|
||||||
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
|
swaks $* --h-Subject: "Swaks HTML" --data @mime-html.raw
|
||||||
|
|
||||||
# Top level HTML test
|
# 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
|
# Attachment test
|
||||||
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
|
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
|
||||||
--body text.txt
|
--body @text.txt
|
||||||
|
|
||||||
# Encoded subject line test
|
# Encoded subject line test
|
||||||
swaks $* --data utf8-subject.raw
|
swaks $* --data @utf8-subject.raw
|
||||||
|
|
||||||
# Gmail test
|
# Gmail test
|
||||||
swaks $* --data gmail.raw
|
swaks $* --data @gmail.raw
|
||||||
|
|
||||||
# Outlook test
|
# Outlook test
|
||||||
swaks $* --data outlook.raw
|
swaks $* --data @outlook.raw
|
||||||
|
|
||||||
# Non-mime responsive HTML test
|
# Non-mime responsive HTML test
|
||||||
swaks $* --data nonmime-html-responsive.raw
|
swaks $* --data @nonmime-html-responsive.raw
|
||||||
swaks $* --data nonmime-html-inlined.raw
|
swaks $* --data @nonmime-html-inlined.raw
|
||||||
|
|
||||||
# Incorrect charset, malformed final boundary
|
# Incorrect charset, malformed final boundary
|
||||||
swaks $* --data mime-errors.raw
|
swaks $* --data @mime-errors.raw
|
||||||
|
|
||||||
# IP RCPT domain
|
# IP RCPT domain
|
||||||
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 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
|
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body @text.txt
|
||||||
|
|
||||||
# Inline attachment test
|
# Inline attachment test
|
||||||
swaks $* --data mime-inline.raw
|
swaks $* --data @mime-inline.raw
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
module github.com/inbucket/inbucket/v3
|
module github.com/inbucket/inbucket/v3
|
||||||
|
|
||||||
go 1.21
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.21.4
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||||
@@ -12,35 +10,38 @@ require (
|
|||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/inbucket/gopher-json v0.2.0
|
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/jhillyerd/goldiff v0.1.0
|
||||||
github.com/kelseyhightower/envconfig v1.4.0
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/yuin/gopher-lua v1.1.1
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
golang.org/x/net v0.29.0
|
golang.org/x/net v0.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // 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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
github.com/inbucket/html2text v1.0.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.31 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // 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/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/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||||
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
|
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/text v0.18.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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/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 h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
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 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
|
||||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
|
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 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
|
||||||
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
|
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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
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/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 h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
github.com/google/subcommands v1.2.0 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/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 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
|
||||||
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
|
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/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
|
||||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
|
||||||
github.com/jhillyerd/enmime/v2 v2.0.0 h1:I39PYf0peLGroKq+uX2yGB1ExH/78HcRJy4VmERQAVk=
|
github.com/jhillyerd/enmime/v2 v2.5.0 h1:V1A3k6CpsAVzTqh5pTBUzOFatHc2M1YAETgVatItrlk=
|
||||||
github.com/jhillyerd/enmime/v2 v2.0.0/go.mod h1:wQkz7BochDzSukAz5ajAQaXOB7pEg5Vh5QWs7m1uAPw=
|
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 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
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 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
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/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
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/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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
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/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
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/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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:noHsffKZsNfU38DwcXWEPldrTjIZ8FPNKx8mYMGnqjs=
|
||||||
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7/go.mod h1:bbMEM6aU1WDF1ErA5YJ0p91652pGv140gGw4Ww3RGp8=
|
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.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
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=
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
|
|||||||
|
|
||||||
// Pre-load, parse, and compile script.
|
// Pre-load, parse, and compile script.
|
||||||
if fi, err := os.Stat(scriptPath); err != nil {
|
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
|
return nil, ErrNoScript
|
||||||
} else if fi.IsDir() {
|
} else if fi.IsDir() {
|
||||||
return nil, fmt.Errorf("lua script %v is a directory", scriptPath)
|
return nil, fmt.Errorf("lua script %v is a directory", scriptPath)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func (s *StoreManager) Deliver(
|
|||||||
// Parse envelope headers.
|
// Parse envelope headers.
|
||||||
header, err := enmime.DecodeHeaders(source)
|
header, err := enmime.DecodeHeaders(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to parse message headers")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ func (s *StoreManager) Deliver(
|
|||||||
for _, mb := range inbound.Mailboxes {
|
for _, mb := range inbound.Mailboxes {
|
||||||
// Append recipient and timestamp to generated Received header.
|
// Append recipient and timestamp to generated Received header.
|
||||||
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
|
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.
|
// Deliver message.
|
||||||
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
|
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
|
||||||
delivery := &Delivery{
|
delivery := &Delivery{
|
||||||
@@ -124,7 +125,7 @@ func (s *StoreManager) Deliver(
|
|||||||
Subject: inbound.Subject,
|
Subject: inbound.Subject,
|
||||||
Size: inbound.Size,
|
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)
|
id, err := s.Store.AddMessage(delivery)
|
||||||
if err != nil {
|
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")
|
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.
|
// Returns an empty StoreManager and extension Host pair, configured for testing.
|
||||||
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
||||||
extHost := extension.NewHost()
|
extHost := extension.NewHost()
|
||||||
|
|||||||
+10
-10
@@ -81,7 +81,7 @@ func TestHubZeroLen(t *testing.T) {
|
|||||||
hub := New(0, extension.NewHost())
|
hub := New(0, extension.NewHost())
|
||||||
go hub.Start(ctx)
|
go hub.Start(ctx)
|
||||||
m := event.MessageMetadata{}
|
m := event.MessageMetadata{}
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
hub.Dispatch(m)
|
hub.Dispatch(m)
|
||||||
}
|
}
|
||||||
// Ensures Hub doesn't panic
|
// Ensures Hub doesn't panic
|
||||||
@@ -93,7 +93,7 @@ func TestHubZeroListeners(t *testing.T) {
|
|||||||
hub := New(5, extension.NewHost())
|
hub := New(5, extension.NewHost())
|
||||||
go hub.Start(ctx)
|
go hub.Start(ctx)
|
||||||
m := event.MessageMetadata{}
|
m := event.MessageMetadata{}
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
hub.Dispatch(m)
|
hub.Dispatch(m)
|
||||||
}
|
}
|
||||||
// Ensures Hub doesn't panic
|
// Ensures Hub doesn't panic
|
||||||
@@ -178,7 +178,7 @@ func TestHubHistoryReplay(t *testing.T) {
|
|||||||
|
|
||||||
// Broadcast 3 messages with no listeners
|
// Broadcast 3 messages with no listeners
|
||||||
msgs := make([]event.MessageMetadata, 3)
|
msgs := make([]event.MessageMetadata, 3)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
msgs[i] = event.MessageMetadata{
|
msgs[i] = event.MessageMetadata{
|
||||||
Subject: fmt.Sprintf("subj %v", i),
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ func TestHubHistoryReplay(t *testing.T) {
|
|||||||
t.Fatal("Timeout:", l2)
|
t.Fatal("Timeout:", l2)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
got := l2.messages[i].Subject
|
got := l2.messages[i].Subject
|
||||||
want := msgs[i].Subject
|
want := msgs[i].Subject
|
||||||
if got != want {
|
if got != want {
|
||||||
@@ -222,7 +222,7 @@ func TestHubHistoryDelete(t *testing.T) {
|
|||||||
|
|
||||||
// Broadcast 3 messages with no listeners
|
// Broadcast 3 messages with no listeners
|
||||||
msgs := make([]event.MessageMetadata, 3)
|
msgs := make([]event.MessageMetadata, 3)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
msgs[i] = event.MessageMetadata{
|
msgs[i] = event.MessageMetadata{
|
||||||
Mailbox: "hub",
|
Mailbox: "hub",
|
||||||
ID: strconv.Itoa(i),
|
ID: strconv.Itoa(i),
|
||||||
@@ -253,7 +253,7 @@ func TestHubHistoryDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"subj 0", "subj 2"}
|
want := []string{"subj 0", "subj 2"}
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
got := l2.messages[i].Subject
|
got := l2.messages[i].Subject
|
||||||
if got != want[i] {
|
if got != want[i] {
|
||||||
t.Errorf("msg[%v].Subject == %q, want %q", i, 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
|
// Broadcast more messages than the hub can hold
|
||||||
msgs := make([]event.MessageMetadata, 20)
|
msgs := make([]event.MessageMetadata, 20)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
msgs[i] = event.MessageMetadata{
|
msgs[i] = event.MessageMetadata{
|
||||||
Subject: fmt.Sprintf("subj %v", i),
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ func TestHubHistoryReplayWrap(t *testing.T) {
|
|||||||
t.Fatal("Timeout:", l2)
|
t.Fatal("Timeout:", l2)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
got := l2.messages[i].Subject
|
got := l2.messages[i].Subject
|
||||||
want := msgs[i+15].Subject
|
want := msgs[i+15].Subject
|
||||||
if got != want {
|
if got != want {
|
||||||
@@ -326,7 +326,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
|
|||||||
|
|
||||||
// Broadcast more messages than the hub can hold.
|
// Broadcast more messages than the hub can hold.
|
||||||
msgs := make([]event.MessageMetadata, 10)
|
msgs := make([]event.MessageMetadata, 10)
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
msgs[i] = event.MessageMetadata{
|
msgs[i] = event.MessageMetadata{
|
||||||
Mailbox: "first",
|
Mailbox: "first",
|
||||||
ID: strconv.Itoa(i),
|
ID: strconv.Itoa(i),
|
||||||
@@ -343,7 +343,7 @@ func TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
|
|||||||
hub.Delete("first", "7")
|
hub.Delete("first", "7")
|
||||||
|
|
||||||
// Broadcast another set of messages.
|
// Broadcast another set of messages.
|
||||||
for i := 0; i < len(msgs); i++ {
|
for i := range msgs {
|
||||||
msgs[i] = event.MessageMetadata{
|
msgs[i] = event.MessageMetadata{
|
||||||
Mailbox: "second",
|
Mailbox: "second",
|
||||||
ID: strconv.Itoa(i),
|
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,
|
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
|
||||||
// not To headers.
|
// not To headers.
|
||||||
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
|
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
|
||||||
|
if address == "" {
|
||||||
|
return &Origin{
|
||||||
|
addrPolicy: a,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
local, domain, err := ParseEmailAddress(address)
|
local, domain, err := ParseEmailAddress(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -269,7 +274,7 @@ func parseEmailAddress(address string) (local string, domain string, err error)
|
|||||||
inCharQuote := false
|
inCharQuote := false
|
||||||
inStringQuote := false
|
inStringQuote := false
|
||||||
LOOP:
|
LOOP:
|
||||||
for i := 0; i < len(address); i++ {
|
for i := range len(address) {
|
||||||
c := address[i]
|
c := address[i]
|
||||||
switch {
|
switch {
|
||||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
|
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)
|
result = strings.ToLower(localPart)
|
||||||
invalid := make([]byte, 0, 10)
|
invalid := make([]byte, 0, 10)
|
||||||
for i := 0; i < len(result); i++ {
|
for i := range len(result) {
|
||||||
c := result[i]
|
c := result[i]
|
||||||
switch {
|
switch {
|
||||||
case 'a' <= c && c <= 'z':
|
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.
|
// 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
|
// This function parses a RCPT TO path, not a To header. See rfc5321#section-4.1.2
|
||||||
func TestRecipientAddress(t *testing.T) {
|
func TestRecipientAddress(t *testing.T) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
"github.com/jhillyerd/enmime/v2"
|
"github.com/jhillyerd/enmime/v2"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
func TestRestMailboxList(t *testing.T) {
|
func TestRestMailboxList(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
mm := test.NewManager()
|
mm := test.NewManager()
|
||||||
logbuf := setupWebServer(mm)
|
logbuf := setupWebServer(mm, &msghub.Hub{})
|
||||||
|
|
||||||
// Test invalid mailbox name
|
// Test invalid mailbox name
|
||||||
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
||||||
@@ -122,7 +123,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
func TestRestMessage(t *testing.T) {
|
func TestRestMessage(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
mm := test.NewManager()
|
mm := test.NewManager()
|
||||||
logbuf := setupWebServer(mm)
|
logbuf := setupWebServer(mm, &msghub.Hub{})
|
||||||
|
|
||||||
// Test invalid mailbox name
|
// Test invalid mailbox name
|
||||||
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
|
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) {
|
func TestRestMarkSeen(t *testing.T) {
|
||||||
mm := test.NewManager()
|
mm := test.NewManager()
|
||||||
logbuf := setupWebServer(mm)
|
logbuf := setupWebServer(mm, &msghub.Hub{})
|
||||||
// Create some messages.
|
// Create some messages.
|
||||||
tzPDT := time.FixedZone("PDT", -7*3600)
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
tzPST := time.FixedZone("PST", -8*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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/stringutil"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// newMsgListenerV1 creates a v1 API listener which relays message headers only; deletions are
|
||||||
// Time allowed to write a message to the peer.
|
// ignored by the socketv1 API.
|
||||||
writeWaitV1 = 10 * time.Second
|
func newMsgListenerV1(hub *msghub.Hub, mailbox string) *msgListener[*model.JSONMessageHeaderV1] {
|
||||||
|
return newMsgListener(hub, mailbox,
|
||||||
// Send pings to peer with this period. Must be less than pongWait.
|
func(msg event.MessageMetadata) *model.JSONMessageHeaderV1 {
|
||||||
pingPeriodV1 = (pongWaitV1 * 9) / 10
|
return metadataToHeader(&msg)
|
||||||
|
},
|
||||||
// Time allowed to read the next pong message from the peer.
|
nil)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
|
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
|
||||||
// the client of all messages received.
|
// the client of all messages received.
|
||||||
func MonitorAllMessagesV1(
|
func MonitorAllMessagesV1(
|
||||||
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) error {
|
||||||
// Upgrade to Websocket.
|
return serveMonitor(w, req, ctx.MsgHub, "", newMsgListenerV1)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
|
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
|
||||||
// notifies the client of messages received by a particular mailbox.
|
// notifies the client of messages received by a particular mailbox.
|
||||||
func MonitorMailboxMessagesV1(
|
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"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Upgrade to Websocket.
|
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV1)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-197
@@ -2,224 +2,48 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
"github.com/inbucket/inbucket/v3/pkg/msghub"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
"github.com/inbucket/inbucket/v3/pkg/rest/model"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
"github.com/inbucket/inbucket/v3/pkg/server/web"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// newMsgListenerV2 creates a v2 API listener which relays message-stored and message-deleted
|
||||||
// Time allowed to write a message to the peer.
|
// events.
|
||||||
writeWaitV2 = 10 * time.Second
|
func newMsgListenerV2(hub *msghub.Hub, mailbox string) *msgListener[*model.JSONMonitorEventV2] {
|
||||||
|
return newMsgListener(hub, mailbox,
|
||||||
// Send pings to peer with this period. Must be less than pongWait.
|
func(msg event.MessageMetadata) *model.JSONMonitorEventV2 {
|
||||||
pingPeriodV2 = (pongWaitV2 * 9) / 10
|
return &model.JSONMonitorEventV2{
|
||||||
|
Variant: "message-stored",
|
||||||
// Time allowed to read the next pong message from the peer.
|
Header: metadataToHeader(&msg),
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}
|
func(mailbox string, id string) *model.JSONMonitorEventV2 {
|
||||||
|
return &model.JSONMonitorEventV2{
|
||||||
return nil
|
Variant: "message-deleted",
|
||||||
}
|
Identifier: &model.JSONMessageIDV2{
|
||||||
|
Mailbox: mailbox,
|
||||||
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
ID: id,
|
||||||
func (ml *msgListenerV2) WSReader(conn *websocket.Conn) {
|
},
|
||||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
|
||||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
|
||||||
defer ml.Close()
|
|
||||||
|
|
||||||
conn.SetReadLimit(maxMessageSizeV2)
|
|
||||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
|
|
||||||
slog.Warn().Err(err).Msg("Failed to setup read deadline")
|
|
||||||
}
|
|
||||||
conn.SetPongHandler(func(string) error {
|
|
||||||
slog.Debug().Msg("Got pong")
|
|
||||||
if err := conn.SetReadDeadline(time.Now().Add(pongWaitV2)); err != nil {
|
|
||||||
slog.Warn().Err(err).Msg("Failed to set read deadline in pong")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
for {
|
|
||||||
if _, _, err := conn.ReadMessage(); err != nil {
|
|
||||||
if websocket.IsUnexpectedCloseError(
|
|
||||||
err,
|
|
||||||
websocket.CloseNormalClosure,
|
|
||||||
websocket.CloseGoingAway,
|
|
||||||
websocket.CloseNoStatusReceived,
|
|
||||||
) {
|
|
||||||
// Unexpected close code
|
|
||||||
slog.Warn().Err(err).Msg("Socket error")
|
|
||||||
} else {
|
|
||||||
slog.Debug().Msg("Closing socket")
|
|
||||||
}
|
}
|
||||||
break
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WSWriter makes sure the websocket client is still connected
|
|
||||||
func (ml *msgListenerV2) WSWriter(conn *websocket.Conn) {
|
|
||||||
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
|
||||||
Str("remote", conn.RemoteAddr().String()).Logger()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(pingPeriodV2)
|
|
||||||
defer func() {
|
|
||||||
ticker.Stop()
|
|
||||||
ml.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Handle messages from hub until msgListener is closed
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case event, ok := <-ml.c:
|
|
||||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
|
|
||||||
slog.Warn().Err(err).Msg("Failed to set write deadline for msg")
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
// msgListener closed, exit
|
|
||||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if conn.WriteJSON(event) != nil {
|
|
||||||
// Write failed
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-ticker.C:
|
|
||||||
// Send ping
|
|
||||||
if err := conn.SetWriteDeadline(time.Now().Add(writeWaitV2)); err != nil {
|
|
||||||
slog.Warn().Err(err).Msg("Failed to set write deadline for ping")
|
|
||||||
}
|
|
||||||
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
|
||||||
// Write error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Debug().Msg("Sent ping")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close removes the listener registration
|
|
||||||
func (ml *msgListenerV2) Close() {
|
|
||||||
select {
|
|
||||||
case <-ml.c:
|
|
||||||
// Already closed
|
|
||||||
default:
|
|
||||||
ml.hub.RemoveListener(ml)
|
|
||||||
close(ml.c)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
|
// MonitorAllMessagesV2 is a web handler which upgrades the connection to a websocket and notifies
|
||||||
// the client of all messages received.
|
// the client of all messages received.
|
||||||
func MonitorAllMessagesV2(
|
func MonitorAllMessagesV2(
|
||||||
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) error {
|
||||||
// Upgrade to Websocket.
|
return serveMonitor(w, req, ctx.MsgHub, "", newMsgListenerV2)
|
||||||
conn, err := upgraderV2.Upgrade(w, req, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
web.ExpWebSocketConnectsCurrent.Add(1)
|
|
||||||
defer func() {
|
|
||||||
_ = conn.Close()
|
|
||||||
web.ExpWebSocketConnectsCurrent.Add(-1)
|
|
||||||
}()
|
|
||||||
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
|
||||||
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
|
||||||
// Create, register listener; then interact with conn.
|
|
||||||
ml := newMsgListenerV2(ctx.MsgHub, "")
|
|
||||||
go ml.WSWriter(conn)
|
|
||||||
ml.WSReader(conn)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorMailboxMessagesV2 is a web handler which upgrades the connection to a websocket and
|
// MonitorMailboxMessagesV2 is a web handler which upgrades the connection to a websocket and
|
||||||
// notifies the client of messages received by a particular mailbox.
|
// notifies the client of messages received by a particular mailbox.
|
||||||
func MonitorMailboxMessagesV2(
|
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"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Upgrade to Websocket.
|
return serveMonitor(w, req, ctx.MsgHub, name, newMsgListenerV2)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error)
|
|||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWebServer(mm message.Manager) *bytes.Buffer {
|
func setupWebServer(mm message.Manager, hub *msghub.Hub) *bytes.Buffer {
|
||||||
// Capture log output
|
// Capture log output
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
log.SetOutput(buf)
|
log.SetOutput(buf)
|
||||||
@@ -63,7 +63,7 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
web.NewServer(cfg, mm, &msghub.Hub{})
|
web.NewServer(cfg, mm, hub)
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-176
@@ -2,6 +2,7 @@ package pop3
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -59,19 +60,20 @@ var commands = map[string]bool{
|
|||||||
|
|
||||||
// Session defines an active POP3 session
|
// Session defines an active POP3 session
|
||||||
type Session struct {
|
type Session struct {
|
||||||
*Server // Reference to the server we belong to.
|
*Server // Reference to the server we belong to.
|
||||||
id int // Session ID number.
|
id int // Session ID number.
|
||||||
conn net.Conn // Our network connection.
|
conn net.Conn // Our network connection.
|
||||||
remoteHost string // IP address of client.
|
remoteHost string // IP address of client.
|
||||||
sendError error // Used to bail out of read loop on send error.
|
sendError error // Used to bail out of read loop on send error.
|
||||||
state State // Current session state.
|
state State // Current session state.
|
||||||
reader *bufio.Reader // Buffered reader for our net conn.
|
reader *bufio.Reader // Buffered reader for our net conn.
|
||||||
user string // Mailbox name.
|
user string // Mailbox name.
|
||||||
messages []storage.Message // Slice of messages in mailbox.
|
messages []storage.Message // Slice of messages in mailbox.
|
||||||
retain []bool // Messages to retain upon UPDATE (true=retain).
|
retain []bool // Messages to retain upon UPDATE (true=retain).
|
||||||
msgCount int // Number of undeleted messages.
|
msgCount int // Number of undeleted messages.
|
||||||
logger zerolog.Logger // Session specific logger.
|
logger zerolog.Logger // Session specific logger.
|
||||||
debug bool // Print network traffic to stdout.
|
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
|
// NewSession creates a new POP3 session
|
||||||
@@ -101,16 +103,17 @@ func (s *Session) String() string {
|
|||||||
* 4. If bad cmd, respond error
|
* 4. If bad cmd, respond error
|
||||||
* 5. Goto 2
|
* 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()).
|
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
|
||||||
Int("session", id).Logger()
|
Int("session", id).Logger()
|
||||||
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
|
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
|
||||||
connToClose := conn
|
connToClose := conn
|
||||||
|
var tlsState *tls.ConnectionState
|
||||||
if s.config.ForceTLS {
|
if s.config.ForceTLS {
|
||||||
logger.Debug().Msg("Setting up TLS for ForceTLS")
|
logger.Debug().Msg("Setting up TLS for ForceTLS")
|
||||||
tlsConn := tls.Server(conn, s.tlsConfig)
|
tlsConn := tls.Server(conn, s.tlsConfig)
|
||||||
s.tlsState = new(tls.ConnectionState)
|
tlsState = new(tls.ConnectionState)
|
||||||
*s.tlsState = tlsConn.ConnectionState()
|
*tlsState = tlsConn.ConnectionState()
|
||||||
conn = tlsConn
|
conn = tlsConn
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ssn := NewSession(s, id, conn, logger)
|
ssn := NewSession(s, id, conn, logger)
|
||||||
|
ssn.tlsState = tlsState
|
||||||
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
|
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
|
||||||
time.Now().Unix(), s.config.Domain))
|
time.Now().Unix(), s.config.Domain))
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
ssn.send("USER")
|
ssn.send("USER")
|
||||||
ssn.send("UIDL")
|
ssn.send("UIDL")
|
||||||
ssn.send("IMPLEMENTATION Inbucket")
|
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("STLS")
|
||||||
}
|
}
|
||||||
ssn.send(".")
|
ssn.send(".")
|
||||||
@@ -165,7 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
// Send command to handler for current state
|
// Send command to handler for current state
|
||||||
switch ssn.state {
|
switch ssn.state {
|
||||||
case AUTHORIZATION:
|
case AUTHORIZATION:
|
||||||
ssn.authorizationHandler(cmd, arg)
|
ssn.authorizationHandler(ctx, cmd, arg)
|
||||||
continue
|
continue
|
||||||
case TRANSACTION:
|
case TRANSACTION:
|
||||||
ssn.transactionHandler(cmd, arg)
|
ssn.transactionHandler(cmd, arg)
|
||||||
@@ -206,7 +210,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AUTHORIZATION state
|
// AUTHORIZATION state
|
||||||
func (s *Session) authorizationHandler(cmd string, args []string) {
|
func (s *Session) authorizationHandler(ctx context.Context, cmd string, args []string) {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
s.send("+OK Goodnight and good luck")
|
s.send("+OK Goodnight and good luck")
|
||||||
@@ -214,7 +218,7 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
|
|||||||
s.enterState(QUIT)
|
s.enterState(QUIT)
|
||||||
|
|
||||||
case "STLS":
|
case "STLS":
|
||||||
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
|
if !s.config.TLSEnabled || s.config.ForceTLS {
|
||||||
// Invalid command since TLS unconfigured.
|
// Invalid command since TLS unconfigured.
|
||||||
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
|
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
|
||||||
s.send("-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.")
|
s.logger.Debug().Msg("Initiating TLS context.")
|
||||||
|
|
||||||
// Start TLS connection handshake.
|
// Start TLS connection handshake.
|
||||||
|
tlsCtx, cancel := context.WithTimeout(ctx, s.config.Timeout)
|
||||||
|
defer cancel()
|
||||||
s.send("+OK Begin TLS Negotiation")
|
s.send("+OK Begin TLS Negotiation")
|
||||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
tlsConn := tls.Server(s.conn, s.tlsConfig)
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.HandshakeContext(tlsCtx); err != nil {
|
||||||
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
|
// RFC 2595 §4: the negotiation must complete before any further
|
||||||
s.ooSeq(cmd)
|
// 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.conn = tlsConn
|
||||||
s.reader = bufio.NewReader(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))
|
s.send(fmt.Sprintf("+OK %v %v", count, size))
|
||||||
case "LIST":
|
case "LIST":
|
||||||
if len(args) > 1 {
|
s.listMessages("LIST", args, func(m storage.Message) string {
|
||||||
s.logger.Warn().Msgf("LIST command had more than 1 argument")
|
return strconv.FormatInt(m.Size(), 10)
|
||||||
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(".")
|
|
||||||
}
|
|
||||||
case "UIDL":
|
case "UIDL":
|
||||||
if len(args) > 1 {
|
s.listMessages("UIDL", args, func(m storage.Message) string {
|
||||||
s.logger.Warn().Msgf("UIDL command had more than 1 argument")
|
return m.ID()
|
||||||
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(".")
|
|
||||||
}
|
|
||||||
case "DELE":
|
case "DELE":
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
|
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
|
||||||
s.send("-ERR DELE command requires a single argument")
|
s.send("-ERR DELE command requires a single argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
msgNum, ok := s.validateMsgNum("DELE", "DELE", args[0])
|
||||||
if err != nil {
|
if !ok {
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.retain[msgNum-1] {
|
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")
|
s.send("-ERR RETR command requires a single argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
msgNum, ok := s.validateMsgNum("RETR", "RETR", args[0])
|
||||||
if err != nil {
|
if !ok {
|
||||||
s.logger.Warn().Msgf("RETR command argument was not an integer")
|
|
||||||
s.send("-ERR RETR command requires an integer argument")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msgNum < 1 {
|
if !s.retain[msgNum-1] {
|
||||||
s.logger.Warn().Msgf("RETR command argument was less than 1")
|
s.logger.Warn().Msgf("Client tried to RETR a message it had deleted")
|
||||||
s.send("-ERR RETR argument must be greater than 0")
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
|
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":
|
case "TOP":
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
|
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
|
||||||
s.send("-ERR TOP command requires two arguments")
|
s.send("-ERR TOP command requires two arguments")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
msgNum, ok := s.validateMsgNum("TOP", "TOP first", args[0])
|
||||||
if err != nil {
|
if !ok {
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines int64
|
var lines int64
|
||||||
lines, err = strconv.ParseInt(args[1], 10, 32)
|
lines, err := strconv.ParseInt(args[1], 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn().Msgf("TOP command second argument was not an integer")
|
s.logger.Warn().Msgf("TOP command second argument was not an integer")
|
||||||
s.send("-ERR TOP command requires an integer argument")
|
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")
|
s.send("-ERR TOP second argument must be non-negative")
|
||||||
return
|
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.send("+OK Top of message follows")
|
||||||
s.sendMessageTop(s.messages[msgNum-1], int(lines))
|
s.sendMessageLines(s.messages[msgNum-1], int(lines))
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
s.send("+OK We will process your deletes")
|
s.send("+OK We will process your deletes")
|
||||||
s.processDeletes()
|
s.processDeletes()
|
||||||
@@ -472,41 +387,65 @@ func (s *Session) transactionHandler(cmd string, args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the contents of the message to the client
|
// listMessages implements the LIST and UIDL commands, which differ only in
|
||||||
func (s *Session) sendMessage(msg storage.Message) {
|
// the value reported for each message.
|
||||||
reader, err := msg.Source()
|
func (s *Session) listMessages(cmd string, args []string, value func(m storage.Message) string) {
|
||||||
if err != nil {
|
if len(args) > 1 {
|
||||||
s.logger.Error().Msgf("Failed to read message for RETR command")
|
s.logger.Warn().Msgf("%v command had more than 1 argument", cmd)
|
||||||
s.send("-ERR Failed to RETR that message, internal error")
|
s.send(fmt.Sprintf("-ERR %v command must have zero or one argument", cmd))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
if len(args) == 1 {
|
||||||
if err := reader.Close(); err != nil {
|
msgNum, ok := s.validateMsgNum(cmd, cmd, args[0])
|
||||||
s.logger.Error().Msgf("Failed to close message: %v", err)
|
if !ok {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}()
|
if !s.retain[msgNum-1] {
|
||||||
|
s.logger.Warn().Msgf("Client tried to %v a message it had deleted", cmd)
|
||||||
scanner := bufio.NewScanner(reader)
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
for scanner.Scan() {
|
return
|
||||||
line := scanner.Text()
|
|
||||||
// Lines starting with . must be prefixed with another .
|
|
||||||
if strings.HasPrefix(line, ".") {
|
|
||||||
line = "." + line
|
|
||||||
}
|
}
|
||||||
s.send(line)
|
s.send(fmt.Sprintf("+OK %v %v", msgNum, value(s.messages[msgNum-1])))
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
return
|
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(".")
|
s.send(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the headers plus the top N lines to the client
|
// validateMsgNum parses arg as a message number and range-checks it against
|
||||||
func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
|
// 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()
|
reader, err := msg.Source()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error().Msgf("Failed to read message for RETR command")
|
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
|
line = "." + line
|
||||||
}
|
}
|
||||||
if inBody {
|
if inBody {
|
||||||
// Check if we need to send anymore lines
|
if maxBodyLines >= 0 && maxBodyLines < 1 {
|
||||||
if lineCount < 1 {
|
|
||||||
break
|
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)
|
s.send(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNoTLS(t *testing.T) {
|
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) {
|
func TestForceTLS(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
server := setupPOPServer(t, ds, true, true)
|
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
|
// net.Pipe does not implement deadlines
|
||||||
type mockConn struct {
|
type mockConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
@@ -302,7 +375,7 @@ func setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Se
|
|||||||
cfg := config.POP3{
|
cfg := config.POP3{
|
||||||
Addr: "127.0.0.1:2500",
|
Addr: "127.0.0.1:2500",
|
||||||
Domain: "inbucket.local",
|
Domain: "inbucket.local",
|
||||||
Timeout: 5,
|
Timeout: 5 * time.Second,
|
||||||
Debug: true,
|
Debug: true,
|
||||||
ForceTLS: forceTLS,
|
ForceTLS: forceTLS,
|
||||||
}
|
}
|
||||||
@@ -344,7 +417,7 @@ func setupPOPSession(t *testing.T, server *Server) net.Conn {
|
|||||||
// Start the session.
|
// Start the session.
|
||||||
server.wg.Add(1)
|
server.wg.Add(1)
|
||||||
sessionNum++
|
sessionNum++
|
||||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
go server.startSession(context.Background(), sessionNum, &mockConn{serverConn})
|
||||||
|
|
||||||
return clientConn
|
return clientConn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/storage"
|
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,6 @@ type Server struct {
|
|||||||
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||||
notify chan error // Notify on fatal error.
|
notify chan error // Notify on fatal error.
|
||||||
tlsConfig *tls.Config // TLS encryption configuration.
|
tlsConfig *tls.Config // TLS encryption configuration.
|
||||||
tlsState *tls.ConnectionState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new, unstarted, POP3 server.
|
// 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()
|
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
|
||||||
tlsConfig := &tls.Config{}
|
tlsConfig := &tls.Config{}
|
||||||
if pop3Config.TLSEnabled {
|
if pop3Config.TLSEnabled {
|
||||||
var err error
|
reloader, err := tlscert.NewReloader(pop3Config.TLSCert, pop3Config.TLSPrivKey, slog)
|
||||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
|
||||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||||
return nil, fmt.Errorf("failed to configure TLS; %v", err)
|
return nil, fmt.Errorf("failed to configure TLS; %v", err)
|
||||||
// Do not silently turn off Security.
|
// Do not silently turn off Security.
|
||||||
}
|
}
|
||||||
|
tlsConfig.GetCertificate = reloader.GetCertificate
|
||||||
slog.Debug().Msg("TLS config available")
|
slog.Debug().Msg("TLS config available")
|
||||||
} else {
|
} else {
|
||||||
tlsConfig = nil
|
tlsConfig = nil
|
||||||
@@ -120,7 +119,7 @@ func (s *Server) serve(ctx context.Context) {
|
|||||||
} else {
|
} else {
|
||||||
tempDelay = 0
|
tempDelay = 0
|
||||||
s.wg.Add(1)
|
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-" + readyBanner)
|
||||||
s.send("250-8BITMIME")
|
s.send("250-8BITMIME")
|
||||||
s.send("250-AUTH PLAIN LOGIN")
|
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("250-STARTTLS")
|
||||||
}
|
}
|
||||||
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
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) {
|
func (s *Session) readyHandler(cmd string, arg string) {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "STARTTLS":
|
case "STARTTLS":
|
||||||
if !s.Server.config.TLSEnabled {
|
if !s.config.TLSEnabled {
|
||||||
// Invalid command since TLS unconfigured.
|
// Invalid command since TLS unconfigured.
|
||||||
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||||
s.send("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.
|
// Start TLS connection handshake.
|
||||||
s.send("220 STARTTLS")
|
s.send("220 STARTTLS")
|
||||||
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
tlsConn := tls.Server(s.conn, s.tlsConfig)
|
||||||
s.conn = tlsConn
|
s.conn = tlsConn
|
||||||
s.text = textproto.NewConn(s.conn)
|
s.text = textproto.NewConn(s.conn)
|
||||||
s.tlsState = new(tls.ConnectionState)
|
s.tlsState = new(tls.ConnectionState)
|
||||||
@@ -411,18 +411,6 @@ func (s *Session) parseMailFromCmd(arg string) {
|
|||||||
from := m[1]
|
from := m[1]
|
||||||
s.logger.Debug().Msgf("Mail sender is %v", from)
|
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.
|
// Parse ESMTP parameters.
|
||||||
if m[2] != "" {
|
if m[2] != "" {
|
||||||
// Here the client may put BODY=8BITMIME, but Inbucket already
|
// 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.
|
// Ignore ShouldAccept if extensions explicitly allowed this From.
|
||||||
if extAction == event.ActionDefer && !s.from.ShouldAccept() {
|
if extAction == event.ActionDefer && !s.from.ShouldAccept() {
|
||||||
s.send("501 Unauthorized domain")
|
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
|
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) {
|
func TestEmptyEnvelope(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
server := setupSMTPServer(ds, extension.NewHost())
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
@@ -85,14 +90,14 @@ func TestEmptyEnvelope(t *testing.T) {
|
|||||||
// Test out some empty envelope without blanks
|
// Test out some empty envelope without blanks
|
||||||
script := []scriptStep{
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM:<>", 501},
|
{"MAIL FROM:<>", 250},
|
||||||
}
|
}
|
||||||
playSession(t, server, script)
|
playSession(t, server, script)
|
||||||
|
|
||||||
// Test out some empty envelope with blanks
|
// Test out some empty envelope with blanks
|
||||||
script = []scriptStep{
|
script = []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM: <>", 501},
|
{"MAIL FROM: <>", 250},
|
||||||
}
|
}
|
||||||
playSession(t, server, script)
|
playSession(t, server, script)
|
||||||
}
|
}
|
||||||
@@ -450,7 +455,6 @@ func TestBeforeMailFromAcceptedEventResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, tc := range tcs {
|
for name, tc := range tcs {
|
||||||
tc := tc
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
// Reset event listener.
|
// Reset event listener.
|
||||||
shouldReturn = &tc.eventRes
|
shouldReturn = &tc.eventRes
|
||||||
@@ -619,7 +623,6 @@ func TestBeforeRcptToAcceptedEventResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, tc := range tcs {
|
for name, tc := range tcs {
|
||||||
tc := tc
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
// Reset event listener.
|
// Reset event listener.
|
||||||
shouldReturn = &tc.eventRes
|
shouldReturn = &tc.eventRes
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/metric"
|
"github.com/inbucket/inbucket/v3/pkg/metric"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,14 +80,13 @@ func NewServer(
|
|||||||
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||||
tlsConfig := &tls.Config{}
|
tlsConfig := &tls.Config{}
|
||||||
if smtpConfig.TLSEnabled {
|
if smtpConfig.TLSEnabled {
|
||||||
var err error
|
reloader, err := tlscert.NewReloader(smtpConfig.TLSCert, smtpConfig.TLSPrivKey, slog)
|
||||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
|
||||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||||
slog.Error().Msg("Disabling STARTTLS support")
|
slog.Error().Msg("Disabling STARTTLS support")
|
||||||
smtpConfig.TLSEnabled = false
|
smtpConfig.TLSEnabled = false
|
||||||
} else {
|
} else {
|
||||||
|
tlsConfig.GetCertificate = reloader.GetCertificate
|
||||||
slog.Debug().Msg("STARTTLS feature available")
|
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
|
// Start begins listening for HTTP requests
|
||||||
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
func (s *Server) Start(ctx context.Context, readyFunc func()) {
|
||||||
|
var (
|
||||||
|
err error
|
||||||
|
listenCfg net.ListenConfig
|
||||||
|
)
|
||||||
|
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: rootConfig.Web.Addr,
|
Addr: rootConfig.Web.Addr,
|
||||||
Handler: requestLoggingWrapper(Router),
|
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
|
// 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).
|
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
|
||||||
Msg("HTTP listening on tcp4")
|
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 {
|
if err != nil {
|
||||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
Msg("HTTP failed to start TCP4 listener")
|
Msg("HTTP failed to start TCP4 listener")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func TestMaxSize(t *testing.T) {
|
|||||||
for _, mailbox := range boxes {
|
for _, mailbox := range boxes {
|
||||||
go func(mailbox string) {
|
go func(mailbox string) {
|
||||||
size := int64(0)
|
size := int64(0)
|
||||||
for i := 0; i < n; i++ {
|
for range n {
|
||||||
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
|
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
|
||||||
size += nbytes
|
size += nbytes
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
|
|||||||
}
|
}
|
||||||
for _, msg := range m.mailboxes[mailbox] {
|
for _, msg := range m.mailboxes[mailbox] {
|
||||||
if msg.ID == id {
|
if msg.ID == id {
|
||||||
msg.MessageMetadata.Seen = true
|
msg.Seen = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func testMetadata(s storeSuite) {
|
|||||||
// testContent generates some binary content and makes sure it is correctly retrieved.
|
// testContent generates some binary content and makes sure it is correctly retrieved.
|
||||||
func testContent(s storeSuite) {
|
func testContent(s storeSuite) {
|
||||||
content := make([]byte, 5000)
|
content := make([]byte, 5000)
|
||||||
for i := 0; i < len(content); i++ {
|
for i := range content {
|
||||||
content[i] = byte(i % 256)
|
content[i] = byte(i % 256)
|
||||||
}
|
}
|
||||||
mailbox := "testmailbox"
|
mailbox := "testmailbox"
|
||||||
@@ -379,7 +379,7 @@ func testPurge(s storeSuite) {
|
|||||||
}
|
}
|
||||||
gotEvents = append(gotEvents, ev)
|
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")
|
"expected delete event for each message in mailbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ func testMsgCap(s storeSuite) {
|
|||||||
mbCap := 10
|
mbCap := 10
|
||||||
mailbox := "captain"
|
mailbox := "captain"
|
||||||
|
|
||||||
for i := 0; i < 20; i++ {
|
for i := range 20 {
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
subj := fmt.Sprintf("subject %v", i)
|
||||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||||
msgs, err := s.store.GetMessages(mailbox)
|
msgs, err := s.store.GetMessages(mailbox)
|
||||||
@@ -401,10 +401,7 @@ func testMsgCap(s storeSuite) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that the first (oldest) message is correct.
|
// Check that the first (oldest) message is correct.
|
||||||
first := i - mbCap + 1
|
first := max(i-mbCap+1, 0)
|
||||||
if first < 0 {
|
|
||||||
first = 0
|
|
||||||
}
|
|
||||||
firstSubj := fmt.Sprintf("subject %v", first)
|
firstSubj := fmt.Sprintf("subject %v", first)
|
||||||
if firstSubj != msgs[0].Subject() {
|
if firstSubj != msgs[0].Subject() {
|
||||||
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
|
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
|
||||||
@@ -415,7 +412,7 @@ func testMsgCap(s storeSuite) {
|
|||||||
// testNoMsgCap verfies a cap of 0 is not enforced.
|
// testNoMsgCap verfies a cap of 0 is not enforced.
|
||||||
func testNoMsgCap(s storeSuite) {
|
func testNoMsgCap(s storeSuite) {
|
||||||
mailbox := "captain"
|
mailbox := "captain"
|
||||||
for i := 0; i < 20; i++ {
|
for i := range 20 {
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
subj := fmt.Sprintf("subject %v", i)
|
||||||
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
|
||||||
GetAndCountMessages(s.T, s.store, mailbox, i+1)
|
GetAndCountMessages(s.T, s.store, mailbox, i+1)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func TestStoreStubMailboxAddGetVisit(t *testing.T) {
|
|||||||
{mailbox: "box3", count: 3},
|
{mailbox: "box3", count: 3},
|
||||||
}
|
}
|
||||||
for _, tc := range tcs {
|
for _, tc := range tcs {
|
||||||
tc := tc
|
|
||||||
t.Run(tc.mailbox, func(t *testing.T) {
|
t.Run(tc.mailbox, func(t *testing.T) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<recipient@inbucket.org>]
|
To: [<recipient@inbucket.org>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 204
|
Size: 242
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
||||||
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
||||||
Subject: Test of ȇɲʢȯȡɪɴʛ
|
Subject: Test of ȇɲʢȯȡɪɴʛ
|
||||||
Size: 338
|
Size: 376
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: From User <fromuser@inbucket.org>
|
From: From User <fromuser@inbucket.org>
|
||||||
To: [Rec I. Pient <recipient@inbucket.org>]
|
To: [Rec I. Pient <recipient@inbucket.org>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 233
|
Size: 271
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ Mailbox: ip4recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<ip4recipient@[192.168.123.123]>]
|
To: [<ip4recipient@[192.168.123.123]>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 180
|
Size: 218
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
No-To message.
|
No-To message.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ Mailbox: ip6recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
|
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 180
|
Size: 218
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
No-To message.
|
No-To message.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{ pkgs ? import <nixpkgs> { } }:
|
{
|
||||||
|
pkgs ? import <nixpkgs> { },
|
||||||
|
}:
|
||||||
let
|
let
|
||||||
scripts = {
|
scripts = {
|
||||||
# Quick test script.
|
# Quick test script.
|
||||||
@@ -22,13 +24,13 @@ pkgs.mkShell {
|
|||||||
elmPackages.elm-json
|
elmPackages.elm-json
|
||||||
elmPackages.elm-language-server
|
elmPackages.elm-language-server
|
||||||
elmPackages.elm-test
|
elmPackages.elm-test
|
||||||
go_1_21
|
go_1_25
|
||||||
golangci-lint
|
golangci-lint
|
||||||
golint
|
golint
|
||||||
gopls
|
gopls
|
||||||
nodejs_20
|
nodejs_22
|
||||||
nodePackages.node-gyp
|
node-gyp
|
||||||
nodePackages.yarn
|
yarn
|
||||||
rpm
|
rpm
|
||||||
swaks
|
swaks
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -17,14 +17,14 @@
|
|||||||
"browserslist": "defaults",
|
"browserslist": "defaults",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||||
"@parcel/packager-raw-url": "^2.11.0",
|
"@parcel/packager-raw-url": "^2.16.4",
|
||||||
"@parcel/transformer-elm": "^2.11.0",
|
"@parcel/transformer-elm": "^2.16.4",
|
||||||
"@parcel/transformer-webmanifest": "^2.11.0",
|
"@parcel/transformer-webmanifest": "^2.16.4",
|
||||||
"@webcomponents/webcomponentsjs": "^2.5.0",
|
"@webcomponents/webcomponentsjs": "^2.5.0",
|
||||||
"opensans-npm-webfont": "^1.0.0",
|
"opensans-npm-webfont": "^1.0.0",
|
||||||
"parcel": "^2.11.0",
|
"parcel": "^2.16.4",
|
||||||
"parcel-namer-rewrite": ">=2.10.3-rc.2"
|
"parcel-namer-rewrite": "^2.16.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"elm": "^0.19.1-6"
|
"elm": "^0.19.1-6"
|
||||||
|
|||||||
+1438
-1393
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user