1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 17:47:03 +00:00

Compare commits

..

2 Commits

Author SHA1 Message Date
James Hillyerd
0738791ba8 Update CHANGELOG for 3.0.4 2022-10-02 15:53:32 -07:00
James Hillyerd
66831d10c7 backport FROM <> fix from #291 for bug #283 (#296)
* bump nix go to 1.18

* backport FROM <> fix from #291 for bug #283
2022-10-02 15:32:49 -07:00
122 changed files with 2992 additions and 8108 deletions

1
.envrc
View File

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

5
.gitattributes vendored
View File

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

View File

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

View File

@@ -1,78 +1,35 @@
name: Build and Test
on:
push:
branches:
- main
pull_request:
jobs:
linux-go-build:
go-build:
runs-on: ubuntu-latest
name: Linux Go ${{ matrix.go }} build
strategy:
matrix:
go:
- '1.21'
go: [ '1.18', '1.17' ]
name: Go ${{ matrix.go }} build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
check-latest: true
- name: Build and test
run: |
go build ./...
go test -race -coverprofile=profile.cov ./...
- name: Send coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
flag-name: Linux-Go-${{ matrix.go }}
flag-name: Go-${{ matrix.go }}
parallel: true
windows-go-build:
runs-on: windows-latest
name: Windows Go build
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Build
run: go build ./...
- name: Test
run: go test -race -coverprofile="profile.cov" ./...
- name: Send coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
flag-name: Windows-Go
parallel: true
coverage:
needs:
- linux-go-build
- windows-go-build
needs: go-build
name: Test Coverage
runs-on: ubuntu-latest
steps:
- uses: shogo82148/actions-goveralls@v1
with:

View File

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

View File

@@ -1,20 +0,0 @@
name: Lint Go Code
on:
push:
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
# Disable cache to prevent `File exists` errors.
# https://github.com/golangci/golangci-lint-action/issues/135
skip-pkg-cache: true

View File

@@ -1,50 +1,39 @@
name: Build and Release
on:
push:
branches:
- main
tags:
- 'v*'
branches: [ "main" ]
tags: [ "v*" ]
pull_request:
jobs:
release:
name: 'Go Releaser'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v2
with:
go-version: '1.21'
check-latest: true
go-version: 1.18
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: '20.x'
node-version: '16.x'
cache: 'yarn'
cache-dependency-path: ui/yarn.lock
- name: Build frontend
run: |
yarn install --frozen-lockfile --non-interactive
yarn run build
working-directory: ./ui
- name: Test build release
uses: goreleaser/goreleaser-action@v5
uses: goreleaser/goreleaser-action@v2
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v5
uses: goreleaser/goreleaser-action@v2
if: "startsWith(github.ref, 'refs/tags/v')"
with:
version: latest

12
.gitignore vendored
View File

@@ -3,9 +3,6 @@
*.a
*.so
# Emacs messiness.
*~
# Folders
_obj
_test
@@ -33,8 +30,6 @@ tags.*
# Desktop Services Store on macOS
.DS_Store
/.direnv
# Inbucket binaries
/client
/client.exe
@@ -58,10 +53,3 @@ repl-temp-*
# Dependency directories
/ui/node_modules
/ui/.parcel-cache
# Test lua files
/inbucket.lua
# IntelliJ
.idea
inbucket.iml

View File

@@ -1,79 +0,0 @@
run:
timeout: 5m
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- containedctx
- contextcheck
- decorder
# - dupl
# - dupword
- durationcheck
- errchkjson
- errname
# - errorlint
- execinquery
# - exhaustive
- exportloopref
# - forcetypeassert
- ginkgolinter
- gocheckcompilerdirectives
# - gochecknoinits
- gochecksumtype
- gocritic
# - godot
# - goerr113
- gofmt
# - gofumpt
- goheader
- goimports
- gomoddirectives
- gomodguard
- goprintffuncname
# - gosec
- gosmopolitan
- grouper
- importas
- inamedparam
- interfacebloat
- loggercheck
- makezero
- mirror
- misspell
- musttag
- nilerr
# - nilnil
# - nlreturn
- noctx
- nolintlint
- nosprintfhostport
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
# - revive
- rowserrcheck
- sloglint
- stylecheck
- tagliatelle
- tenv
- testableexamples
- testifylint
- thelper
- tparallel
# - unconvert
- unparam
- usestdlibvars
- wastedassign
- whitespace
- zerologlint
linters-settings:
tagliatelle:
case:
rules:
json: kebab

View File

@@ -50,6 +50,8 @@ archives:
- id: tarball
format: tar.gz
wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides:
- goos: windows
format: zip
@@ -72,7 +74,7 @@ nfpms:
license: MIT
contents:
- src: "ui/dist/**"
dst: "/usr/share/inbucket/ui"
dst: "/usr/local/share/inbucket/ui"
- src: "etc/linux/inbucket.service"
dst: "/lib/systemd/system/inbucket.service"
type: config|noreplace
@@ -80,6 +82,9 @@ nfpms:
dst: "/etc/inbucket/greeting.html"
type: config|noreplace
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'

View File

@@ -6,44 +6,6 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v3.1.0-beta2] - 2024-02-05
### Added
- Reject mail by origin domain: `INBUCKET_SMTP_REJECTORIGINDOMAINS` (#375)
- Wildcard support (#412)
- Version flag for `inbucket` cmd (#385)
- STLS support for POP3 (#384)
- ForceTLS flag for SMTP (#402)
- Lua scripting additions:
- `logger` API for Lua (#407)
- `before.message_stored` handler (#417, #418)
- `$` is replaced with `:` in filestore paths, for `D:\...` syntax (#449)
- REST Client `transport` support (#463)
### Fixed
- UI & Storage paths in systemd service file (#393)
- Web UI will redirect from `prefix` to `prefix/` (#397)
- Include inlines when listing attachments (#398)
- Fail Inbucket startup if unable to create storage dir (#448)
- Close directory file handles immediately, fixes Windows locking (#457)
## [v3.1.0-beta1] - 2023-02-28
### Added
- Monitor tab updates when messages are deleted (#337)
- Initial framework for extensions
- Initial Lua scripting implementation, supporting events:
- `after.message_deleted`
- `after.message_stored`
- `before.mail_accepted`
- Provide `http` and `json` modules for Lua scripts
### Fixed
- Support for IP address as domain in RCPT TO (#285)
## [v3.0.4] - 2022-10-02
### Fixed
@@ -353,9 +315,7 @@ No change from beta1.
specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...main
[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
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.4...main
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
@@ -392,10 +352,9 @@ No change from beta1.
- Update previous tag version for *Unreleased*
3. Run tests
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release PR into main
6. Create new release via GitHub, use CHANGELOG release notes, tag `vX.Y.Z`
7. Push tags and wait for
5. Commit changes and merge release into main, tag `vX.Y.Z`
6. Push tags and wait for
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
-- it will add compiled release assets
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -2,8 +2,7 @@
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:20 as frontend
RUN npm install -g node-gyp
FROM --platform=linux/amd64 node:16 as frontend
WORKDIR /build
COPY . .
WORKDIR /build/ui
@@ -12,7 +11,7 @@ RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.21-alpine3.19 as backend
FROM golang:1.18-alpine3.16 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
@@ -23,7 +22,7 @@ RUN go build -o inbucket \
-v ./cmd/inbucket
### Run in minimal image
FROM alpine:3.19
FROM alpine:3.16
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
@@ -38,7 +37,7 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
ENV INBUCKET_POP3_TIMEOUT 30s
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR ui
ENV INBUCKET_WEB_UIDIR=ui
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h

View File

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

View File

@@ -77,7 +77,7 @@ version can be found at https://github.com/inbucket/inbucket
[Configurator]: https://www.inbucket.org/configurator/
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
[Docker Image]: https://inbucket.org/packages/docker.html
[Docker Image]: https://www.inbucket.org/binaries/docker.html
[Elm]: https://elm-lang.org/
[From Source]: https://www.inbucket.org/installation/from-source.html
[Go]: https://golang.org/

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,11 +14,19 @@ import (
"syscall"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/server"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage/file"
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/server/pop3"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -49,7 +57,6 @@ func init() {
func main() {
// Command line flags.
help := flag.Bool("help", false, "Displays help on flags and env variables.")
versionflag := flag.Bool("version", false, "Displays version.")
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
@@ -65,10 +72,6 @@ func main() {
config.Usage()
return
}
if *versionflag {
fmt.Fprintln(os.Stdout, version)
return
}
// Process configuration.
config.Version = version
@@ -111,16 +114,36 @@ func main() {
}
}
// Configure and start internal services.
svcCtx, svcCancel := context.WithCancel(context.Background())
services, err := server.FullAssembly(conf)
// Configure internal services.
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
if err != nil {
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
removePIDFile(*pidfile)
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
}
services.Start(svcCtx, func() {
startupLog.Debug().Msg("All services report ready")
})
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Configure routes and start HTTP server.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel.
signalLoop:
@@ -132,37 +155,30 @@ signalLoop:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
Msg("Received SIGINT, shutting down")
svcCancel()
break signalLoop
close(shutdownChan)
case syscall.SIGTERM:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
Msg("Received SIGTERM, shutting down")
svcCancel()
break signalLoop
close(shutdownChan)
}
case <-services.Notify():
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
svcCancel()
case <-shutdownChan:
rootCancel()
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
services.SMTPServer.Drain()
log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
services.POP3Server.Drain()
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
services.RetentionScanner.Join()
smtpServer.Drain()
pop3Server.Drain()
retentionScanner.Join()
removePIDFile(*pidfile)
closeLog()
}
// openLog configures zerolog output, returns func to close logfile.
func openLog(level string, logfile string, json bool) (closeLog func(), err error) {
func openLog(level string, logfile string, json bool) (close func(), err error) {
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
@@ -173,10 +189,9 @@ func openLog(level string, logfile string, json bool) (closeLog func(), err erro
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
return nil, fmt.Errorf("log level %q not one of: debug, info, warn, error", level)
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
}
closeLog = func() {}
close = func() {}
var w io.Writer
color := runtime.GOOS != "windows"
switch logfile {
@@ -192,24 +207,21 @@ func openLog(level string, logfile string, json bool) (closeLog func(), err erro
bw := bufio.NewWriter(logf)
w = bw
color = false
closeLog = func() {
close = func() {
_ = bw.Flush()
_ = logf.Close()
}
}
w = zerolog.SyncWriter(w)
if json {
log.Logger = log.Output(w)
return closeLog, nil
return close, nil
}
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: w,
NoColor: !color,
})
return closeLog, nil
return close, nil
}
// removePIDFile removes the PID file if created.

View File

@@ -9,8 +9,7 @@ variables it supports:
KEY DEFAULT DESCRIPTION
INBUCKET_LOGLEVEL info debug, info, warn, or error
INBUCKET_LUA_PATH inbucket.lua Lua script path
INBUCKET_MAILBOXNAMING local Use local, full, or domain addressing
INBUCKET_MAILBOXNAMING local Use local or full addressing
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
INBUCKET_SMTP_DOMAIN inbucket HELO domain
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
@@ -18,7 +17,6 @@ variables it supports:
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
INBUCKET_SMTP_REJECTORIGINDOMAINS Domains to reject mail from
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
@@ -58,16 +56,6 @@ off with `warn` or `error`.
- Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error`
### Lua Script
`INBUCKET_LUA_PATH`
This is the path to the (optional) Inbucket Lua script. If the specified file
is present, Inbucket will load it during startup. Ignored if the file is not
found, or the setting is empty.
- Default: `inbucket.lua`
### Mailbox Naming
`INBUCKET_MAILBOXNAMING`
@@ -162,7 +150,7 @@ List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of recipient domains
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Rejected Recipient Domain List
@@ -173,22 +161,7 @@ List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
has no effect when false.
- Default: None
- Values: Comma separated list of recipient domains
- Example: `reject.com,gmail.com`
### Rejected Origin Domain List
`INBUCKET_SMTP_REJECTORIGINDOMAINS`
List of domains to reject mail from. This list is enforced regardless of the
`INBUCKET_SMTP_DEFAULTACCEPT` value.
Enforcement takes place during evalation of the `MAIL FROM` SMTP command, the
origin domain is extracted from the address presented and compared against the
list. It does not take email headers into account.
- Default: None
- Values: Comma separated list of origin domains
- Values: Comma separated list of domains
- Example: `reject.com,gmail.com`
### Default Recipient Store Policy
@@ -210,7 +183,7 @@ List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of recipient domains
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Discarded Recipient Domain List
@@ -223,7 +196,7 @@ emails. Messages sent to a domain other than this will be stored normally.
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
- Default: None
- Values: Comma separated list of recipient domains
- Values: Comma separated list of domains
- Example: `recycle.com,loadtest.org`
### Network Idle Timeout
@@ -442,8 +415,7 @@ separated list of key:value pairs.
#### `file` type parameters
- `path`: Operating system specific path to the directory where mail should be
stored. `$` characters will be replaced with `:` in the final path value,
allowing Windows drive letters, i.e. `D$\inbucket`.
stored.
#### `memory` type parameters

View File

@@ -3,7 +3,6 @@
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
#export INBUCKET_MAILBOXNAMING="domain"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
@@ -29,7 +28,7 @@ fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'yarn build' from the 'ui' directory." >&2
echo "Run 'npm run build' from the 'ui' directory." >&2
exit 1
fi

View File

@@ -13,7 +13,5 @@ of 300 messages per mailbox - the oldest messages will be deleted to stay under
that limit.</p>
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
domain will be accepted, but immediately <b>discarded</b> without being written
to disk. Use this domain for load or soak testing your application. Inbucket
will retain mail for any other domain by default, i.e.
<code>@inbucket.local</code>.</p>
domain will be accepted but not written to disk. Use this domain for load or
soak testing your application.</p>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/bash
# run-tests.sh
# description: Generate test emails for Inbucket
@@ -59,10 +59,3 @@ swaks $* --data nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw
# IP RCPT domain
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body text.txt
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body text.txt
# Inline attachment test
swaks $* --data mime-inline.raw

59
go.mod
View File

@@ -1,46 +1,23 @@
module github.com/inbucket/inbucket/v3
go 1.21
toolchain go1.21.4
module github.com/inbucket/inbucket
require (
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
github.com/cosmotek/loguago v1.0.0
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/inbucket/gopher-json v0.2.0
github.com/jhillyerd/enmime v1.1.0
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/microcosm-cc/bluemonday v1.0.26
github.com/rs/zerolog v1.32.0
github.com/stretchr/testify v1.8.4
github.com/yuin/gopher-lua v1.1.1
golang.org/x/net v0.20.0
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/jhillyerd/enmime v0.9.2
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.17
github.com/rs/zerolog v1.26.1
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
go 1.13

131
go.sum
View File

@@ -2,91 +2,90 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cosmotek/loguago v1.0.0 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
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/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/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.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 h1:noHsffKZsNfU38DwcXWEPldrTjIZ8FPNKx8mYMGnqjs=
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7/go.mod h1:bbMEM6aU1WDF1ErA5YJ0p91652pGv140gGw4Ww3RGp8=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/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=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -8,7 +8,7 @@ import (
"text/tabwriter"
"time"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
@@ -51,58 +51,46 @@ func (n *mbNaming) Decode(v string) error {
case "domain":
*n = DomainNaming
default:
return fmt.Errorf("unknown MailboxNaming strategy: %q", v)
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
}
return nil
}
// Root contains global configuration, and structs with for specific sub-systems.
type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
Lua Lua
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
SMTP SMTP
POP3 POP3
Web Web
Storage Storage
}
// Lua contains the Lua extension host configuration.
type Lua struct {
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
}
// SMTP contains the SMTP server configuration.
type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
RejectOriginDomains []string `desc:"Domains to reject mail from"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
ForceTLS bool `default:"false" desc:"Listen for connections with TLS."`
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
}
// POP3 contains the POP3 server configuration.
type POP3 struct {
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
TLSEnabled bool `default:"false" desc:"Enable TLS"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
}
// Web contains the HTTP server configuration.
@@ -134,7 +122,6 @@ func Process() (*Root, error) {
stringutil.SliceToLower(c.SMTP.RejectDomains)
stringutil.SliceToLower(c.SMTP.StoreDomains)
stringutil.SliceToLower(c.SMTP.DiscardDomains)
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
return c, err
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
package event
import (
"net/mail"
"time"
)
// AddressParts contains the local and domain parts of an email address.
type AddressParts struct {
Local string
Domain string
}
// InboundMessage contains the basic header and mailbox data for a message being received.
type InboundMessage struct {
Mailboxes []string
From *mail.Address
To []*mail.Address
Subject string
Size int64
}
// MessageMetadata contains the basic header data for a message event.
type MessageMetadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
package luahost
import (
"net/mail"
"testing"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
function assert_eq(got, want)
if type(got) == "table" and type(want) == "table" then
assert(#got == #want, string.format("got %d element(s), wanted %d", #got, #want))
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
end
return
end
assert(got == want, string.format("got %q, wanted %q", got, want))
end
`
func TestInboundMessageGetters(t *testing.T) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{
{Name: "name2", Address: "addr2"},
{Name: "name3", Address: "addr3"},
},
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
assert_eq(msg.mailboxes, {"mb1", "mb2"})
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42)
assert_eq(msg.from.name, "name1")
assert_eq(msg.from.address, "addr1")
assert_eq(#msg.to, 2)
assert_eq(msg.to[1].name, "name2")
assert_eq(msg.to[1].address, "addr2")
assert_eq(msg.to[2].name, "name3")
assert_eq(msg.to[2].address, "addr3")
`
ls := lua.NewState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
require.NoError(t, ls.DoString(LuaInit+script))
}
func TestInboundMessageSetters(t *testing.T) {
want := &event.InboundMessage{
Mailboxes: []string{"mb1", "mb2"},
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{
{Name: "name2", Address: "addr2"},
{Name: "name3", Address: "addr3"},
},
Subject: "subj1",
}
script := `
assert(msg, "msg should not be nil")
msg.mailboxes = {"mb1", "mb2"}
msg.subject = "subj1"
msg.from = address.new("name1", "addr1")
msg.to = { address.new("name2", "addr2"), address.new("name3", "addr3") }
`
got := &event.InboundMessage{}
ls := lua.NewState()
registerInboundMessageType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapInboundMessage(ls, got))
require.NoError(t, ls.DoString(script))
assert.Equal(t, want, got)
}

View File

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

View File

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

View File

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

View File

@@ -1,91 +0,0 @@
package luahost
import (
"net/mail"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func TestMessageMetadataGetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
function assert_eq(got, want)
assert(got == want, string.format("got name %q, wanted %q", got, want))
end
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
assert_eq(msg.subject, "subj1")
assert_eq(msg.size, 42)
assert_eq(msg.from.name, "name1")
assert_eq(msg.from.address, "addr1")
assert_eq(table.getn(msg.to), 1)
assert_eq(msg.to[1].name, "name2")
assert_eq(msg.to[1].address, "addr2")
assert_eq(msg.date, 981173106)
`
ls := lua.NewState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
require.NoError(t, ls.DoString(script))
}
func TestMessageMetadataSetters(t *testing.T) {
want := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
script := `
assert(msg, "msg should not be nil")
msg.mailbox = "mb1"
msg.id = "id1"
msg.subject = "subj1"
msg.size = 42
msg.from = address.new("name1", "addr1")
msg.to = { address.new("name2", "addr2") }
msg.date = 981173106
`
got := &event.MessageMetadata{}
ls := lua.NewState()
registerMessageMetadataType(ls)
registerMailAddressType(ls)
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))
require.NoError(t, ls.DoString(script))
// Timezones will cause a naive comparison to fail.
assert.Equal(t, want.Date.Unix(), got.Date.Unix())
now := time.Now()
want.Date = now
got.Date = now
assert.Equal(t, want, got)
}

View File

@@ -1,17 +0,0 @@
package luahost
import (
lua "github.com/yuin/gopher-lua"
)
const policyName = "policy"
func registerPolicyType(ls *lua.LState) {
mt := ls.NewTypeMetatable(policyName)
ls.SetGlobal(policyName, mt)
// Static attributes.
ls.SetField(mt, "allow", lua.LTrue)
ls.SetField(mt, "deny", lua.LFalse)
ls.SetField(mt, "defer", lua.LNil)
}

View File

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

View File

@@ -1,258 +0,0 @@
package luahost_test
import (
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
// LuaInit holds useful test globals.
const LuaInit = `
local logger = require("logger")
async = false
test_ok = true
-- Sends marks tests as failed instead of erroring when enabled.
function assert_async(value, message)
if not value then
if async then
logger.error(message, {from = "assert_async"})
test_ok = false
else
error(message)
end
end
end
-- Verifies plain values and list-style tables.
function assert_eq(got, want)
if type(got) == "table" and type(want) == "table" then
assert_async(#got == #want, string.format("got %d elements, wanted %d", #got, #want))
for i, gotv in ipairs(got) do
local wantv = want[i]
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
end
return
end
assert_async(got == want, string.format("got %q, wanted %q", got, want))
end
-- Verifies string want contains string got.
function assert_contains(got, want)
assert_async(string.find(got, want),
string.format("got %q, wanted it to contain %q", got, want))
end
`
var consoleLogger = zerolog.New(zerolog.NewConsoleWriter())
func TestEmptyScript(t *testing.T) {
script := ""
extHost := extension.NewHost()
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
}
func TestLogger(t *testing.T) {
script := `
local logger = require("logger")
logger.info("_test log entry_", {})
`
extHost := extension.NewHost()
output := &strings.Builder{}
logger := zerolog.New(output)
_, err := luahost.NewFromReader(logger, extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
assert.Contains(t, output.String(), "_test log entry_")
}
func TestAfterMessageDeleted(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_deleted(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageDeleted.Emit(msg)
assertNotified(t, notify)
}
func TestAfterMessageStored(t *testing.T) {
// Register lua event listener, setup notify channel.
script := `
async = true
function inbucket.after.message_stored(msg)
-- Full message bindings tested elsewhere.
assert_eq(msg.mailbox, "mb1")
assert_eq(msg.id, "id1")
notify:send(test_ok)
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event, check channel response is true.
msg := &event.MessageMetadata{
Mailbox: "mb1",
ID: "id1",
From: &mail.Address{Name: "name1", Address: "addr1"},
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
Subject: "subj1",
Size: 42,
}
extHost.Events.AfterMessageStored.Emit(msg)
assertNotified(t, notify)
}
func TestBeforeMailAccepted(t *testing.T) {
// Register lua event listener.
script := `
function inbucket.before.mail_accepted(localpart, domain)
return localpart == "from" and domain == "test"
end
`
extHost := extension.NewHost()
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
require.NoError(t, err)
// Send event to be accepted.
addr := &event.AddressParts{Local: "from", Domain: "test"}
got := extHost.Events.BeforeMailAccepted.Emit(addr)
want := true
require.NotNil(t, got, "Expected result from Emit()")
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
}
// Send event to be denied.
addr = &event.AddressParts{Local: "reject", Domain: "me"}
got = extHost.Events.BeforeMailAccepted.Emit(addr)
want = false
require.NotNil(t, got, "Expected result from Emit()")
if *got != want {
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
}
}
func TestBeforeMessageStored(t *testing.T) {
// Event to send.
msg := event.InboundMessage{
Mailboxes: []string{"one", "two"},
From: &mail.Address{Name: "From Name", Address: "from@example.com"},
To: []*mail.Address{
{Name: "To1 Name", Address: "to1@example.com"},
{Name: "To2 Name", Address: "to2@example.com"},
},
Subject: "inbound subj",
Size: 42,
}
// Register lua event listener.
script := `
async = true
function inbucket.before.message_stored(msg)
-- Verify incoming values.
assert_eq(msg.mailboxes, {"one", "two"})
assert_eq(msg.from.name, "From Name")
assert_eq(msg.from.address, "from@example.com")
assert_eq(2, #msg.to)
assert_eq(msg.to[1].name, "To1 Name")
assert_eq(msg.to[1].address, "to1@example.com")
assert_eq(msg.to[2].name, "To2 Name")
assert_eq(msg.to[2].address, "to2@example.com")
assert_eq(msg.subject, "inbound subj")
assert_eq(msg.size, 42)
notify:send(test_ok)
-- Generate response.
res = inbound_message.new()
res.mailboxes = {"resone", "restwo"}
res.from = address.new("Res From", "res@example.com")
res.to = {
address.new("To1 Res", "res1@example.com"),
address.new("To2 Res", "res2@example.com"),
}
res.subject = "res subj"
return res
end
`
extHost := extension.NewHost()
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
require.NoError(t, err)
notify := luaHost.CreateChannel("notify")
// Send event to be accepted.
got := extHost.Events.BeforeMessageStored.Emit(&msg)
require.NotNil(t, got, "Expected result from Emit()")
// Verify Lua assertions passed.
assertNotified(t, notify)
// Verify response values.
want := &event.InboundMessage{
Mailboxes: []string{"resone", "restwo"},
From: &mail.Address{Name: "Res From", Address: "res@example.com"},
To: []*mail.Address{
{Name: "To1 Res", Address: "res1@example.com"},
{Name: "To2 Res", Address: "res2@example.com"},
},
Subject: "res subj",
Size: 0,
}
assert.Equal(t, want, got, "Response InboundMessage did not match")
}
func assertNotified(t *testing.T, notify chan lua.LValue) {
t.Helper()
select {
case reslv := <-notify:
// Lua function received event.
if lua.LVIsFalse(reslv) {
t.Error("Lua responsed with false, wanted true")
}
case <-time.After(2 * time.Second):
t.Fatal("Lua did not respond to event within timeout")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,12 @@ package policy
import (
"bytes"
"errors"
"fmt"
"net"
"net/mail"
"strings"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/stringutil"
)
// Addressing handles email address policy.
@@ -19,41 +17,44 @@ type Addressing struct {
// ExtractMailbox extracts the mailbox name from a partial email address.
func (a *Addressing) ExtractMailbox(address string) (string, error) {
if a.Config.MailboxNaming == config.DomainNaming {
return extractDomainMailbox(address)
}
local, domain, err := parseEmailAddress(address)
if err != nil {
return "", err
}
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
if a.Config.MailboxNaming == config.LocalNaming {
return local, nil
}
if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("unknown MailboxNaming value: %v", a.Config.MailboxNaming)
if a.Config.MailboxNaming == config.DomainNaming {
// If no domain is specified, assume this is being
// used for mailbox lookup via the API.
if domain == "" {
if ValidateDomainPart(local) == false {
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
}
return local, nil
}
if ValidateDomainPart(domain) == false {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
}
return domain, nil
}
if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
}
if domain == "" {
return local, nil
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("domain part %q in %q failed validation", domain, address)
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
}
return local + "@" + domain, nil
}
// NewRecipient parses an address into a Recipient. This is used for parsing RCPT TO arguments,
// not To headers.
// NewRecipient parses an address into a Recipient.
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
@@ -63,8 +64,12 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
if err != nil {
return nil, err
}
ar, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
return &Recipient{
Address: mail.Address{Address: address},
Address: *ar,
addrPolicy: a,
LocalPart: local,
Domain: domain,
@@ -72,21 +77,6 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
}, nil
}
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
// not To headers.
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
}
return &Origin{
Address: mail.Address{Address: address},
addrPolicy: a,
LocalPart: local,
Domain: domain,
}, nil
}
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
domain = strings.ToLower(domain)
@@ -115,19 +105,6 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool {
return false
}
// ShouldAcceptOriginDomain indicates if Inbucket accept mail from the specified domain.
func (a *Addressing) ShouldAcceptOriginDomain(domain string) bool {
domain = strings.ToLower(domain)
if len(a.Config.SMTP.RejectOriginDomains) > 0 {
for _, d := range a.Config.SMTP.RejectOriginDomains {
if stringutil.MatchWithWildcards(d, domain) {
return false
}
}
}
return true
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696.
@@ -137,7 +114,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
return "", "", err
}
if !ValidateDomainPart(domain) {
return "", "", errors.New("domain part validation failed")
return "", "", fmt.Errorf("Domain part validation failed")
}
return local, domain, nil
}
@@ -145,24 +122,13 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
// ParseEmailAddress().
func ValidateDomainPart(domain string) bool {
ln := len(domain)
if ln == 0 {
if len(domain) == 0 {
return false
}
if ln > 255 {
if len(domain) > 255 {
return false
}
if ln >= 4 && domain[0] == '[' && domain[ln-1] == ']' {
// Bracketed domains must contain an IP address.
s := 1
if strings.HasPrefix(domain[1:], "IPv6:") {
s = 6
}
ip := net.ParseIP(domain[s : ln-1])
return ip != nil
}
if domain[ln-1] != '.' {
if domain[len(domain)-1] != '.' {
domain += "."
}
prev := '.'
@@ -202,67 +168,22 @@ func ValidateDomainPart(domain string) bool {
return true
}
// Extracts the mailbox name when domain addressing is enabled.
func extractDomainMailbox(address string) (string, error) {
var local, domain string
var err error
if address != "" && address[0] == '[' && address[len(address)-1] == ']' {
// Likely an IP address in brackets, treat as domain only.
domain = address
} else {
local, domain, err = parseEmailAddress(address)
if err != nil {
return "", err
}
}
if local != "" {
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
}
// If no @domain is specified, assume this is being used for mailbox lookup via the API.
if domain == "" {
domain = local
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("domain part %q in %q failed validation", domain, address)
}
return domain, nil
}
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
// error is returned if the local part fails validation following the guidelines in RFC3696. The
// domain part is optional and not validated.
func parseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", errors.New("empty address")
return "", "", fmt.Errorf("empty address")
}
if len(address) > 320 {
return "", "", errors.New("address exceeds 320 characters")
return "", "", fmt.Errorf("address exceeds 320 characters")
}
// Remove forward-path routes.
if address[0] == '@' {
end := strings.IndexRune(address, ':')
if end == -1 {
return "", "", errors.New("missing terminating ':' in route specification")
}
address = address[end+1:]
if address == "" {
return "", "", errors.New("address empty after removing route specification")
}
return "", "", fmt.Errorf("address cannot start with @ symbol")
}
if address[0] == '.' {
return "", "", errors.New("address cannot start with a period")
return "", "", fmt.Errorf("address cannot start with a period")
}
// Loop over address parsing out local part.
buf := new(bytes.Buffer)
prev := byte('.')
@@ -286,7 +207,7 @@ LOOP:
return
}
inCharQuote = false
case strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0:
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted.
err = buf.WriteByte(c)
if err != nil {
@@ -297,7 +218,7 @@ LOOP:
// A single period is OK.
if prev == '.' {
// Sequence of periods is not permitted.
return "", "", errors.New("sequence of periods is not permitted")
return "", "", fmt.Errorf("Sequence of periods is not permitted")
}
err = buf.WriteByte(c)
if err != nil {
@@ -307,20 +228,19 @@ LOOP:
case c == '\\':
inCharQuote = true
case c == '"':
switch {
case inCharQuote:
if inCharQuote {
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case inStringQuote:
} else if inStringQuote {
inStringQuote = false
default:
} else {
if i == 0 {
inStringQuote = true
} else {
return "", "", errors.New("quoted string can only begin at start of address")
return "", "", fmt.Errorf("Quoted string can only begin at start of address")
}
}
case c == '@':
@@ -333,16 +253,16 @@ LOOP:
} else {
// End of local-part.
if i > 128 {
return "", "", errors.New("local part must not exceed 128 characters")
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
}
if prev == '.' {
return "", "", errors.New("local part cannot end with a period")
return "", "", fmt.Errorf("Local part cannot end with a period")
}
domain = address[i+1:]
break LOOP
}
case c > 127:
return "", "", errors.New("characters outside of US-ASCII range not permitted")
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
default:
if inCharQuote || inStringQuote {
err = buf.WriteByte(c)
@@ -351,16 +271,16 @@ LOOP:
}
inCharQuote = false
} else {
return "", "", fmt.Errorf("character %q must be quoted", c)
return "", "", fmt.Errorf("Character %q must be quoted", c)
}
}
prev = c
}
if inCharQuote {
return "", "", errors.New("cannot end address with unterminated quoted-pair")
return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair")
}
if inStringQuote {
return "", "", errors.New("cannot end address with unterminated string quote")
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
}
return buf.String(), domain, nil
}
@@ -371,7 +291,7 @@ LOOP:
// quoted according to RFC3696.
func parseMailboxName(localPart string) (result string, err error) {
if localPart == "" {
return "", errors.New("mailbox name cannot be empty")
return "", fmt.Errorf("Mailbox name cannot be empty")
}
result = strings.ToLower(localPart)
invalid := make([]byte, 0, 10)
@@ -380,13 +300,13 @@ func parseMailboxName(localPart string) (result string, err error) {
switch {
case 'a' <= c && c <= 'z':
case '0' <= c && c <= '9':
case strings.IndexByte("!#$%&'*+-=/?^_`.{|}~", c) >= 0:
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
default:
invalid = append(invalid, c)
}
}
if len(invalid) > 0 {
return "", fmt.Errorf("mailbox name contained invalid character(s): %q", invalid)
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
}
if idx := strings.Index(result, "+"); idx > -1 {
result = result[0:idx]

View File

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

View File

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

View File

@@ -10,10 +10,10 @@ import (
"encoding/json"
"strconv"
"github.com/inbucket/inbucket/v3/pkg/rest/model"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
@@ -26,7 +26,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("failed to get messages for %v: %v", name, err)
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
@@ -108,7 +108,7 @@ func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Contex
dec := json.NewDecoder(req.Body)
dm := model.JSONMessageHeaderV1{}
if err := dec.Decode(&dm); err != nil {
return fmt.Errorf("failed to decode JSON: %v", err)
return fmt.Errorf("Failed to decode JSON: %v", err)
}
if dm.Seen {
err = ctx.Manager.MarkSeen(name, id)

View File

@@ -9,12 +9,26 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/test"
"github.com/jhillyerd/enmime"
)
const (
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
headerKey = "header"
bodyKey = "body"
textKey = "text"
htmlKey = "html"
)
func TestRestMailboxList(t *testing.T) {
// Setup
mm := test.NewManager()
@@ -53,7 +67,7 @@ func TestRestMailboxList(t *testing.T) {
// Test JSON message headers
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := event.MessageMetadata{
meta1 := message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -61,7 +75,7 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := event.MessageMetadata{
meta2 := message.Metadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
@@ -69,8 +83,8 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Check return code
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
@@ -164,7 +178,7 @@ func TestRestMessage(t *testing.T) {
// Test JSON message headers
tzPST := time.FixedZone("PST", -8*3600)
msg1 := message.New(
event.MessageMetadata{
message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -186,10 +200,6 @@ func TestRestMessage(t *testing.T) {
FileName: "favicon.png",
ContentType: "image/png",
}},
Inlines: []*enmime.Part{{
FileName: "statement.pdf",
ContentType: "application/pdf",
}},
},
)
mm.AddMessage("good", msg1)
@@ -225,14 +235,10 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
decodedStringEquals(t, result, "attachments/[0]/filename", "statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/content-type", "application/pdf")
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/statement.pdf")
decodedStringEquals(t, result, "attachments/[1]/filename", "favicon.png")
decodedStringEquals(t, result, "attachments/[1]/content-type", "image/png")
decodedStringEquals(t, result, "attachments/[1]/download-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
decodedStringEquals(t, result, "attachments/[1]/view-link", "http://localhost/serve/mailbox/good/0001/attach/1/favicon.png")
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
if t.Failed() {
// Wait for handler to finish logging
@@ -248,7 +254,7 @@ func TestRestMarkSeen(t *testing.T) {
// Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := event.MessageMetadata{
meta1 := message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
@@ -256,7 +262,7 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := event.MessageMetadata{
meta2 := message.Metadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
@@ -264,8 +270,8 @@ func TestRestMarkSeen(t *testing.T) {
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{MessageMetadata: meta1})
mm.AddMessage("good", &message.Message{MessageMetadata: meta2})
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Mark one read.
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
expectCode := 200

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -12,7 +11,7 @@ import (
// httpClient allows http.Client to be mocked for tests
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
Do(*http.Request) (*http.Response, error)
}
// Generic REST restClient
@@ -22,24 +21,47 @@ type restClient struct {
}
// do performs an HTTP request with this client and returns the response.
func (c *restClient) do(ctx context.Context, method, uri string, body []byte) (*http.Response, error) {
url := c.baseURL.JoinPath(uri)
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url.String(), r)
req, err := http.NewRequest(method, url.String(), r)
if err != nil {
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSON(ctx context.Context, method string, uri string, v interface{}) error {
resp, err := c.do(ctx, method, uri, nil)
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri, nil)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
resp, err := c.do(method, uri, body)
if err != nil {
return err
}

View File

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

View File

@@ -4,7 +4,7 @@ import (
"time"
)
// JSONMessageHeaderV1 contains the basic header data for a message.
// JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
type JSONMessageV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
// JSONMessageAttachmentV1 contains information about a MIME attachment.
// JSONMessageAttachmentV1 contains information about a MIME attachment
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`
@@ -42,7 +42,7 @@ type JSONMessageAttachmentV1 struct {
MD5 string `json:"md5"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body.
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,52 +2,38 @@ package rest
import (
"bytes"
"context"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/server/web"
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, strings.NewReader(body))
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
// Pass request to handlers directly.
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
@@ -62,8 +48,9 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
UIDir: "../ui",
},
}
shutdownChan := make(chan bool)
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.NewServer(cfg, mm, &msghub.Hub{})
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
return buf
}
@@ -124,11 +111,12 @@ func decodedStringEquals(t *testing.T, json interface{}, path string, want strin
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
// brackets require the parent element to be a []interface{}.
//
// getDecodedPath(o, "users", "[1]", "name")
// getDecodedPath(o, "users", "[1]", "name")
//
// is equivalent to the JavaScript:
//
// o.users[1].name
// o.users[1].name
//
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if len(path) == 0 {
return o, ""
@@ -136,10 +124,9 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if o == nil {
return nil, " is nil"
}
var present bool
var val interface{}
key := path[0]
present := false
var val interface{}
if key[0] == '[' {
// Expecting slice.
index, err := strconv.Atoi(strings.Trim(key, "[]"))
@@ -162,15 +149,12 @@ func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
}
val, present = omap[key]
}
if !present {
return nil, "/" + key + " is missing"
}
result, msg := getDecodedPath(val, path[1:]...)
if msg != "" {
return nil, "/" + key + msg
}
return result, ""
}

View File

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

View File

@@ -2,7 +2,6 @@ package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
@@ -11,7 +10,7 @@ import (
"strings"
"time"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -54,7 +53,6 @@ var commands = map[string]bool{
"PASS": true,
"APOP": true,
"CAPA": true,
"STLS": true,
}
// Session defines an active POP3 session
@@ -104,24 +102,11 @@ func (s *Session) String() string {
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
connToClose := conn
if s.config.ForceTLS {
logger.Debug().Msg("Setting up TLS for ForceTLS")
tlsConn := tls.Server(conn, s.tlsConfig)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
conn = tlsConn
}
logger.Info().Msg("Starting POP3 session")
defer func() {
logger.Debug().Msg("closing at end of session")
// Closing the tlsConn hangs.
if err := connToClose.Close(); err != nil {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
logger.Debug().Msg("End of session")
s.wg.Done()
}()
@@ -132,48 +117,46 @@ func (s *Server) startSession(id int, conn net.Conn) {
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
line, err := ssn.readLine()
ssn.logger.Debug().Msgf("read %s", line)
if err == nil {
cmd, arg := ssn.parseCmd(line)
// Commands we handle in any state
if cmd == "CAPA" {
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
ssn.send(".")
continue
}
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
// Commands we handle in any state
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
ssn.send(".")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("-ERR Syntax error, command garbled")
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
// readLine() returned an error
if err == io.EOF {
@@ -210,37 +193,7 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
switch cmd {
case "QUIT":
s.send("+OK Goodnight and good luck")
s.logger.Debug().Msg("Quitting.")
s.enterState(QUIT)
case "STLS":
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
s.send("-ERR TLS unavailable on the server")
s.ooSeq(cmd)
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("-ERR A TLS session already agreed upon.")
s.send("-ERR A TLS session already agreed upon.")
s.ooSeq(cmd)
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("+OK Begin TLS Negotiation")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
s.ooSeq(cmd)
}
s.conn = tlsConn
s.reader = bufio.NewReader(tlsConn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.logger.Debug().Msgf("TLS set %v", *s.tlsState)
case "USER":
if len(args) > 0 {
s.user = args[0]
@@ -461,7 +414,7 @@ func (s *Session) transactionHandler(cmd string, args []string) {
s.processDeletes()
s.enterState(QUIT)
case "NOOP":
s.send("+OK I have successfully done nothing")
s.send("+OK I have sucessfully done nothing")
case "RSET":
// Reset session, don't actually delete anything I told you to
s.logger.Debug().Msgf("Resetting session state on RSET request")
@@ -629,14 +582,14 @@ func (s *Session) readLine() (line string, err error) {
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, args []string) {
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", nil
return "", nil, true
}
words := strings.Split(line, " ")
return strings.ToUpper(words[0]), words[1:]
return strings.ToUpper(words[0]), words[1:], true
}
func (s *Session) reset() {

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
@@ -14,15 +13,18 @@ import (
"strings"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our SMTP state machine.
type State int
const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
@@ -104,7 +106,7 @@ type Session struct {
sendError error // Last network send error.
state State // Session state machine.
reader *bufio.Reader // Buffered reading for TCP conn.
from *policy.Origin // Sender from MAIL command.
from string // Sender from MAIL command.
recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
@@ -117,7 +119,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
session := &Session{
return &Session{
Server: server,
id: id,
conn: conn,
@@ -129,34 +131,26 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
debug: server.config.Debug,
text: textproto.NewConn(conn),
}
if server.config.ForceTLS {
session.tlsState = new(tls.ConnectionState)
*session.tlsState = conn.(*tls.Conn).ConnectionState()
}
return session
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
// Session flow:
// 1. Send initial greeting
// 2. Receive cmd
// 3. If good cmd, respond, optionally change state
// 4. If bad cmd, respond error
// 5. Goto 2
func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
logger = logger.Hook(logHook{}).With().
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.Hook(logHook{}).With().
Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting SMTP session")
// Update WaitGroup and counters.
s.wg.Add(1)
expConnectsCurrent.Add(1)
expConnectsTotal.Add(1)
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
@@ -177,13 +171,13 @@ func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
}
line, err := ssn.readLine()
if err == nil {
// Handle LOGIN/PASSWORD states here, because they don't expect a command.
//Handle LOGIN/PASSWORD states here, because they don't expect a command
switch ssn.state {
case LOGIN:
ssn.loginHandler()
ssn.loginHandler(line)
continue
case PASSWORD:
ssn.passwordHandler()
ssn.passwordHandler(line)
continue
}
@@ -210,7 +204,7 @@ func (s *Server) startSession(id int, conn net.Conn, logger zerolog.Logger) {
ssn.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ssn.send("250 I have successfully done nothing")
ssn.send("250 I have sucessfully done nothing")
continue
case "RSET":
// Reset session
@@ -295,7 +289,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && !s.Server.config.ForceTLS && s.Server.tlsConfig != nil && s.tlsState == nil {
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
@@ -311,18 +305,18 @@ func parseHelloArgument(arg string) (string, error) {
domain = arg[:idx]
}
if domain == "" {
return "", errors.New("invalid domain")
return "", fmt.Errorf("Invalid domain")
}
return domain, nil
}
func (s *Session) loginHandler() {
func (s *Session) loginHandler(line string) {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler() {
func (s *Session) passwordHandler(line string) {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
@@ -331,8 +325,7 @@ func (s *Session) passwordHandler() {
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
switch cmd {
case "STARTTLS":
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
@@ -355,125 +348,88 @@ func (s *Session) readyHandler(cmd string, arg string) {
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
case "AUTH":
} else if cmd == "AUTH" {
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
case "LOGIN":
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
default:
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
case "MAIL":
s.parseMailFromCmd(arg)
case "EHLO":
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else if cmd == "EHLO" {
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
default:
} else {
s.ooSeq(cmd)
}
}
// Parses `MAIL FROM` command.
func (s *Session) parseMailFromCmd(arg string) {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
// Parse from address.
localpart, domain, err := policy.ParseEmailAddress(from)
s.logger.Debug().Msgf("Origin domain is %v", domain)
if from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// Parse ESMTP parameters.
if m[2] != "" {
// Here the client may put BODY=8BITMIME, but Inbucket already
// reads the DATA as bytes, so it does not effect mail processing.
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
// Reject oversized messages.
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
// Process through extensions.
extResult := s.extHost.Events.BeforeMailAccepted.Emit(
&event.AddressParts{Local: localpart, Domain: domain})
if extResult != nil && !*extResult {
s.send("550 Mail denied by policy")
s.logger.Warn().Msgf("Extension denied mail from <%v>", from)
return
}
// Sender was permitted by an extension, or no extension rejected it.
origin, err := s.addrPolicy.ParseOrigin(from)
if err != nil {
s.send("501 Bad origin address syntax")
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
return
}
s.from = origin
if !s.from.ShouldAccept() {
s.send("501 Unauthorized domain")
s.logger.Warn().Msgf("Bad domain sender %s", domain)
return
}
// Ok to transition to MAIL state.
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
}
// MAIL state -> waiting for RCPTs followed by DATA
func (s *Session) mailHandler(cmd string, arg string) {
switch cmd {
@@ -543,24 +499,31 @@ func (s *Session) dataHandler() {
}
mailData := bytes.NewBuffer(msgBuf)
// Generate Received header; Deliver() will append recipient and timestamp to this.
recvdHeader := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain)
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
if err := s.manager.Deliver(s.from, s.recipients, recvdHeader, mailData.Bytes()); err != nil {
// Deliver() logs failure details, and the effected mailbox.
s.send("451 Failed to store message")
s.reset()
return
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
}
// TODO Consider changing this to just 1 regardless of # of recipents.
expReceivedTotal.Add(int64(len(s.recipients)))
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
s.reset()
return
}
func (s *Session) enterState(state State) {
@@ -625,7 +588,6 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
s.logger.Debug().Msgf("Line received: %v", line)
// Find length of command or entire line.
hasArg := true
@@ -653,9 +615,7 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
//
// " BODY=8BITMIME SIZE=1024"
//
// " BODY=8BITMIME SIZE=1024"
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
@@ -674,7 +634,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
func (s *Session) reset() {
s.enterState(READY)
s.from = nil
s.from = ""
s.recipients = nil
}

View File

@@ -1,23 +1,22 @@
package smtp
import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
type scriptStep struct {
@@ -25,40 +24,14 @@ type scriptStep struct {
expect int
}
// Test valid commands in GREET state.
func TestGreetStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"HELO mydomain", 250},
{"HELO mydom.com", 250},
{"HelO mydom.com", 250},
{"helo 127.0.0.1", 250},
{"HELO ABC", 250},
{"EHLO mydomain", 250},
{"EHLO mydom.com", 250},
{"EhlO mydom.com", 250},
{"ehlo 127.0.0.1", 250},
{"EHLO a", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid commands in GREET state.
// Test commands in GREET state
func TestGreetState(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
tests := []scriptStep{
// Test out some mangled HELOs
script := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
@@ -66,40 +39,86 @@ func TestGreetState(t *testing.T) {
{"hello", 500},
{"Outlook", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
// Valid HELOs
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in READY state
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 501},
{"MAIL FROM:<>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
playSession(t, server, script)
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 501},
{"MAIL FROM: <>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
playSession(t, server, script)
}
// Test AUTH commands.
// Test AUTH
func TestAuth(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// PLAIN AUTH
script := []scriptStep{
@@ -112,7 +131,9 @@ func TestAuth(t *testing.T) {
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// LOGIN AUTH
script = []scriptStep{
@@ -125,85 +146,27 @@ func TestAuth(t *testing.T) {
{"", 334},
{"", 235},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test TLS commands.
func TestTLS(t *testing.T) {
// Test commands in READY state
func TestReadyState(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test Start TLS parsing.
// Test out some mangled READY commands
script := []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
playSession(t, server, script)
}
// Test valid commands in READY state.
func TestReadyStateValidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
// Test out some valid MAIL commands
tests := []scriptStep{
{"MAIL FROM:<john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid domains in READY state.
func TestReadyStateRejectedDomains(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"MAIL FROM: <john@validdomain.com>", 250},
{"MAIL FROM: <john@invalidomain.com>", 501},
{"MAIL FROM: <john@s1.otherinvaliddomain.com>", 501},
{"MAIL FROM: <john@s2.otherinvaliddomain.com>", 501},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
}
}
// Test invalid commands in READY state.
func TestReadyStateInvalidCommands(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"FOOB", 500},
{"HELO", 503},
{"DATA", 503},
@@ -215,22 +178,65 @@ func TestReadyStateInvalidCommands(t *testing.T) {
{"MAIL FROM:<first@last@gmail.com>", 501},
{"MAIL FROM:<first last@gmail.com>", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
playSession(t, server, script)
})
// Test out some valid MAIL commands
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"RSET", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test Start TLS parsing.
script = []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in MAIL state
func TestMailState(t *testing.T) {
mds := test.NewStore()
server := setupSMTPServer(mds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script := []scriptStep{
@@ -246,7 +252,9 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<first last@host.com>", 501},
{"RCPT TO:<fred@fish@host.com", 501},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out some good RCPT commands
script = []scriptStep{
@@ -260,10 +268,10 @@ func TestMailState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{`RCPT TO:<"first/last"@host.com`, 250},
{"RCPT TO:<u1@[127.0.0.1]>", 250},
{"RCPT TO:<u1@[IPv6:2001:db8:aaaa:1::100]>", 250},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out recipient limit
script = []scriptStep{
@@ -276,7 +284,9 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u5@gmail.com>", 250},
{"RCPT TO:<u6@gmail.com>", 552},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test DATA
script = []scriptStep{
@@ -286,7 +296,9 @@ func TestMailState(t *testing.T) {
{"DATA", 354},
{".", 250},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test late EHLO, similar to RSET
script = []scriptStep{
@@ -297,7 +309,9 @@ func TestMailState(t *testing.T) {
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test RSET
script = []scriptStep{
@@ -307,7 +321,9 @@ func TestMailState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test QUIT
script = []scriptStep{
@@ -316,16 +332,26 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"QUIT", 221},
}
playSession(t, server, script)
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in DATA state
func TestDataState(t *testing.T) {
mds := test.NewStore()
server := setupSMTPServer(mds, extension.NewHost())
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
var script []scriptStep
pipe := setupSMTPSession(t, server)
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
@@ -337,8 +363,9 @@ func TestDataState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
playScriptAgainst(t, c, script)
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
// Send a message
body := `To: u1@gmail.com
From: john@gmail.com
@@ -352,11 +379,9 @@ Hi!
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
// Test with no useful headers.
pipe = setupSMTPSession(t, server)
pipe = setupSMTPSession(server)
c = textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
@@ -367,128 +392,54 @@ Hi!
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
playScriptAgainst(t, c, script)
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
// Send a message
body = `X-Useless-Header: true
Hi! Can you still deliver this?
`
Hi! Can you still deliver this?
`
dw = c.DotWriter()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
}
// Tests "MAIL FROM" emits BeforeMailAccepted event.
func TestBeforeMailAcceptedEventEmitted(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var got *event.AddressParts
extHost.Events.BeforeMailAccepted.AddListener(
"test",
func(addr event.AddressParts) *bool {
got = &addr
return nil
})
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"QUIT", 221}}
playSession(t, server, script)
assert.NotNil(t, got, "BeforeMailListener did not receive Address")
assert.Equal(t, "john", got.Local, "Address local part had wrong value")
assert.Equal(t, "gmail.com", got.Domain, "Address domain part had wrong value")
}
// Test "MAIL FROM" acts on BeforeMailAccepted event result.
func TestBeforeMailAcceptedEventResponse(t *testing.T) {
ds := test.NewStore()
extHost := extension.NewHost()
server := setupSMTPServer(ds, extHost)
var shouldReturn *bool
var gotEvent *event.AddressParts
extHost.Events.BeforeMailAccepted.AddListener(
"test",
func(addr event.AddressParts) *bool {
gotEvent = &addr
return shouldReturn
})
allowRes := true
denyRes := false
tcs := map[string]struct {
script scriptStep // Command to send and SMTP code expected.
eventRes *bool // Response to send from event listener.
}{
"allow": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: &allowRes,
},
"deny": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 550},
eventRes: &denyRes,
},
"defer": {
script: scriptStep{"MAIL FROM:<john@gmail.com>", 250},
eventRes: nil,
},
}
for name, tc := range tcs {
tc := tc
t.Run(name, func(t *testing.T) {
// Reset event listener.
shouldReturn = tc.eventRes
gotEvent = nil
// Play and verify SMTP session.
script := []scriptStep{
{"HELO localhost", 250},
tc.script,
{"QUIT", 221}}
playSession(t, server, script)
assert.NotNil(t, gotEvent, "BeforeMailListener did not receive Address")
})
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// playSession creates a new session, reads the greeting and then plays the script
func playSession(t *testing.T, server *Server, script []scriptStep) {
t.Helper()
pipe := setupSMTPSession(t, server)
func playSession(t *testing.T, server *Server, script []scriptStep) error {
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("expected a 220 greeting, got %v", code)
return fmt.Errorf("Expected a 220 greeting, got %v", code)
}
playScriptAgainst(t, c, script)
err := playScriptAgainst(t, c, script)
// Not all tests leave the session in a clean state, so the following two calls can fail
// Not all tests leave the session in a clean state, so the following two
// calls can fail
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
return err
}
// playScriptAgainst an existing connection, does not handle server greeting
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) {
t.Helper()
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) error {
for i, step := range script {
id, err := c.Cmd(step.send)
if err != nil {
t.Fatalf("Step %d, failed to send %q: %v", i, step.send, err)
return fmt.Errorf("Step %d, failed to send %q: %v", i, step.send, err)
}
c.StartResponse(id)
@@ -500,10 +451,11 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) {
c.EndResponse(id)
if err != nil {
// Fail after c.EndResponse so we don't hang the connection
t.Fatal(err)
// Return after c.EndResponse so we don't hang the connection
return err
}
}
return nil
}
// net.Pipe does not implement deadlines
@@ -515,46 +467,41 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
// Creates an unstarted smtp.Server.
func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
cfg := &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
RejectOriginDomains: []string{"invalidomain.com", "*.otherinvaliddomain.com"},
Timeout: 5,
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
Timeout: 5,
},
}
// Create a server, but don't start it.
// Capture log output.
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it.
shutdownChan := make(chan bool)
teardown = func() {
close(shutdownChan)
}
addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds, ExtHost: extHost}
return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
manager := &message.StoreManager{Store: ds}
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
return s, buf, teardown
}
var sessionNum int
func setupSMTPSession(t *testing.T, server *Server) net.Conn {
t.Helper()
logger := zerolog.New(zerolog.NewTestWriter(t))
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate.
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
_ = clientConn.Close()
// Drain is required to prevent a test-logging data race. If a (failing) test run is
// hanging, this may be the culprit.
server.Drain()
})
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn}, logger)
go server.startSession(sessionNum, &mockConn{serverConn})
return clientConn
}

View File

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

View File

@@ -5,9 +5,9 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
)
// Context is passed into every request handler function

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -85,13 +86,13 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
}
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
func spaTemplateHandler(tmpl *template.Template, basePath string) http.Handler {
func spaTemplateHandler(tmpl *template.Template, basePath string,
webConfig config.Web) http.Handler {
tmplData := struct {
BasePath string
}{
BasePath: basePath,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// ensure we do now allow click jacking
w.Header().Set("X-Frame-Options", "SameOrigin")

View File

@@ -21,6 +21,6 @@ func TextToHTML(text string) string {
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.ReplaceAll(url, "&amp;", "&")
unescaped := strings.Replace(url, "&amp;", "&", -1)
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}

View File

@@ -15,10 +15,10 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -31,9 +31,10 @@ var (
// incoming requests to the correct handler function
Router = mux.NewRouter()
rootConfig *config.Root
server *http.Server
listener net.Listener
rootConfig *config.Root
server *http.Server
listener net.Listener
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
@@ -44,15 +45,15 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Server defines an instance of the Web server.
type Server struct {
// TODO Migrate global vars here.
notify chan error // Notify on fatal error.
}
// Initialize sets up things for unit tests or the Start() method.
func Initialize(
conf *config.Root,
shutdownChan chan bool,
mm message.Manager,
mh *msghub.Hub) {
// NewServer sets up things for unit tests or the Start() method.
func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers.
msgHub = mh
@@ -65,9 +66,6 @@ func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
Msg("Base path configured")
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
// Redirect prefix when missing trailing slash.
Router.Path(prefix("")).Handler(http.RedirectHandler(redirectBase, http.StatusFound))
}
// Dynamic paths.
@@ -109,7 +107,7 @@ func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
spaTemplateHandler(indexTmpl, prefix("/")))
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
@@ -120,16 +118,10 @@ func NewServer(conf *config.Root, mm message.Manager, mh *msghub.Hub) *Server {
http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path")
s := &Server{
notify: make(chan error, 1),
}
return s
}
// Start begins listening for HTTP requests
func (s *Server) Start(ctx context.Context, readyFunc func()) {
func Start(ctx context.Context) {
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: requestLoggingWrapper(Router),
@@ -145,19 +137,19 @@ func (s *Server) Start(ctx context.Context, readyFunc func()) {
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP failed to start TCP4 listener")
s.notify <- err
close(s.notify)
emergencyShutdown()
return
}
// Start listener go routine
go s.serve(ctx)
readyFunc()
// Listener go routine
go serve(ctx)
// Wait for shutdown
<-ctx.Done()
log.Debug().Str("module", "web").Str("phase", "shutdown").
Msg("HTTP server shutting down on request")
select {
case _ = <-ctx.Done():
log.Debug().Str("module", "web").Str("phase", "shutdown").
Msg("HTTP server shutting down on request")
}
// Closing the listener will cause the serve() go routine to exit
if err := listener.Close(); err != nil {
@@ -184,23 +176,26 @@ func appConfigCookie(webConfig config.Web) *http.Cookie {
}
// serve begins serving HTTP requests
func (s *Server) serve(ctx context.Context) {
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case <-ctx.Done():
case _ = <-ctx.Done():
// Nop
default:
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP server failed")
s.notify <- err
close(s.notify)
emergencyShutdown()
return
}
}
// Notify allows the running Web server to be monitored for a fatal error.
func (s *Server) Notify() <-chan error {
return s.notify
func emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
}

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
// access in most cases without requiring an infinite number of mutexes.
type HashLock [4096]sync.RWMutex
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexadecimal
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
// string of three or more characters.
func (h *HashLock) Get(hash string) *sync.RWMutex {
if len(hash) < 3 {

View File

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

View File

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

View File

@@ -2,15 +2,13 @@ package mem
import (
"fmt"
"io"
"io/ioutil"
"sort"
"strconv"
"sync"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
)
// Store implements an in-memory message store.
@@ -20,7 +18,6 @@ type Store struct {
cap int // Per-mailbox message cap.
incoming chan *msgDone // New messages for size enforcer.
remove chan *msgDone // Remove deleted messages from size enforcer.
extHost *extension.Host
}
type mbox struct {
@@ -33,12 +30,11 @@ type mbox struct {
var _ storage.Store = &Store{}
// New returns an empty memory store.
func New(cfg config.Storage, extHost *extension.Host) (storage.Store, error) {
// New returns an emtpy memory store.
func New(cfg config.Storage) (storage.Store, error) {
s := &Store{
boxes: make(map[string]*mbox),
cap: cfg.MailboxMsgCap,
extHost: extHost,
boxes: make(map[string]*mbox),
cap: cfg.MailboxMsgCap,
}
if str, ok := cfg.Params["maxkb"]; ok {
maxKB, err := strconv.ParseInt(str, 10, 64)
@@ -62,7 +58,7 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
err = ierr
return
}
source, ierr := io.ReadAll(r)
source, ierr := ioutil.ReadAll(r)
if ierr != nil {
err = ierr
return
@@ -82,7 +78,6 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
m.id = id
m.source = source
mb.messages[id] = m
if s.cap > 0 {
// Enforce cap.
for len(mb.messages) > s.cap {
@@ -145,25 +140,16 @@ func (s *Store) MarkSeen(mailbox, id string) error {
// PurgeMessages deletes the contents of a mailbox.
func (s *Store) PurgeMessages(mailbox string) error {
// Grab lock, copy messages, clear, and drop lock.
var messages map[string]*Message
s.withMailbox(mailbox, true, func(mb *mbox) {
messages = mb.messages
mb.messages = make(map[string]*Message)
})
// Process size/quota.
if s.remove != nil {
if len(messages) > 0 && s.remove != nil {
for _, m := range messages {
s.enforcerRemove(m)
}
}
// Emit delete events.
for _, m := range messages {
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return nil
}
@@ -177,11 +163,6 @@ func (s *Store) removeMessage(mailbox, id string) *Message {
delete(mb.messages, id)
}
})
if m != nil {
s.extHost.Events.AfterMessageDeleted.Emit(message.MakeMetadata(m))
}
return m
}

View File

@@ -5,34 +5,28 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/require"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
// TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) {
test.StoreSuite(t,
func(conf config.Storage, extHost *extension.Host) (storage.Store, func(), error) {
s, _ := New(conf, extHost)
destroy := func() {}
return s, destroy, nil
})
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
s, _ := New(conf)
destroy := func() {}
return s, destroy, nil
})
}
// TestMessageList verifies the operation of the global message list: mem.Store.messages.
func TestMaxSize(t *testing.T) {
extHost := extension.NewHost()
maxSize := int64(2048)
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}}, extHost)
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}})
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
// Ensure capacity so we do not block population.
n := 10
// total := 50
sizeChan := make(chan int64, len(boxes))
// Populate mailboxes concurrently.
for _, mailbox := range boxes {
go func(mailbox string) {
@@ -44,23 +38,19 @@ func TestMaxSize(t *testing.T) {
sizeChan <- size
}(mailbox)
}
// Wait for sizes.
sentBytesTotal := int64(0)
for range boxes {
sentBytesTotal += <-sizeChan
}
// Calculate actual size.
gotSize := int64(0)
err := s.VisitMailboxes(func(messages []storage.Message) bool {
s.VisitMailboxes(func(messages []storage.Message) bool {
for _, m := range messages {
gotSize += m.Size()
}
return true
})
require.NoError(t, err, "VisitMailboxes() must succeed")
// Verify state. Messages are ~75 bytes each.
if gotSize < 2048-75 {
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
@@ -68,7 +58,6 @@ func TestMaxSize(t *testing.T) {
if gotSize > maxSize {
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
}
// Purge all messages concurrently, testing for deadlocks.
wg := &sync.WaitGroup{}
wg.Add(len(boxes))
@@ -82,14 +71,11 @@ func TestMaxSize(t *testing.T) {
}(mailbox)
}
wg.Wait()
// Verify zero stored messages.
count := 0
err = s.VisitMailboxes(func(messages []storage.Message) bool {
s.VisitMailboxes(func(messages []storage.Message) bool {
count += len(messages)
return true
})
require.NoError(t, err, "VisitMailboxes() must succeed")
if count != 0 {
t.Errorf("Got %v total messages, want: %v", count, 0)
}

View File

@@ -2,12 +2,11 @@ package storage
import (
"container/list"
"context"
"expvar"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/metric"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/metric"
"github.com/rs/zerolog/log"
)
@@ -51,6 +50,7 @@ func init() {
// RetentionScanner looks for messages older than the configured retention period and deletes them.
type RetentionScanner struct {
globalShutdown chan bool // Closes when Inbucket needs to shut down
retentionShutdown chan bool // Closed after the scanner has shut down
ds Store
retentionPeriod time.Duration
@@ -61,8 +61,10 @@ type RetentionScanner struct {
func NewRetentionScanner(
cfg config.Storage,
ds Store,
shutdownChannel chan bool,
) *RetentionScanner {
rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool),
ds: ds,
retentionPeriod: cfg.RetentionPeriod,
@@ -74,16 +76,20 @@ func NewRetentionScanner(
}
// Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start(ctx context.Context) {
slog := log.With().Str("module", "storage").Logger()
func (rs *RetentionScanner) Start() {
if rs.retentionPeriod <= 0 {
slog.Info().Str("phase", "startup").Msg("Retention scanner disabled")
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled")
close(rs.retentionShutdown)
return
}
slog.Info().Str("phase", "startup").Msgf("Retention configured for %v", rs.retentionPeriod)
log.Info().Str("phase", "startup").Str("module", "storage").
Msgf("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
slog := log.With().Str("module", "storage").Logger()
start := time.Now()
retentionLoop:
for {
@@ -93,19 +99,19 @@ retentionLoop:
dur := time.Minute - since
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
select {
case <-ctx.Done():
case <-rs.globalShutdown:
break retentionLoop
case <-time.After(dur):
}
}
// Kickoff scan
start = time.Now()
if err := rs.DoScan(ctx); err != nil {
if err := rs.DoScan(); err != nil {
slog.Error().Err(err).Msg("Error during retention scan")
}
// Check for global shutdown
select {
case <-ctx.Done():
case <-rs.globalShutdown:
break retentionLoop
default:
}
@@ -115,14 +121,13 @@ retentionLoop:
}
// DoScan does a single pass of all mailboxes looking for messages that can be purged.
func (rs *RetentionScanner) DoScan(ctx context.Context) error {
func (rs *RetentionScanner) DoScan() error {
slog := log.With().Str("module", "storage").Logger()
slog.Debug().Msg("Starting retention scan")
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
// Loop over all mailboxes.
retained := 0
storeSize := int64(0)
// Loop over all mailboxes.
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
for _, msg := range messages {
if msg.Date().Before(cutoff) {
@@ -140,7 +145,7 @@ func (rs *RetentionScanner) DoScan(ctx context.Context) error {
}
}
select {
case <-ctx.Done():
case <-rs.globalShutdown:
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
return false
case <-time.After(rs.retentionSleep):
@@ -151,12 +156,10 @@ func (rs *RetentionScanner) DoScan(ctx context.Context) error {
if err != nil {
return err
}
// Update metrics
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
expRetainedCurrent.Set(int64(retained))
expRetainedSize.Set(storeSize)
return nil
}

View File

@@ -1,21 +1,18 @@
package storage_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"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/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
func TestDoRetentionScan(t *testing.T) {
ds := test.NewStore()
// Mockup some different aged messages (num is in hours)
new1 := stubMessage("mb1", 0)
new2 := stubMessage("mb2", 1)
@@ -23,32 +20,28 @@ func TestDoRetentionScan(t *testing.T) {
old1 := stubMessage("mb1", 4)
old2 := stubMessage("mb1", 12)
old3 := stubMessage("mb2", 24)
_, _ = ds.AddMessage(new1)
_, _ = ds.AddMessage(old1)
_, _ = ds.AddMessage(old2)
_, _ = ds.AddMessage(old3)
_, _ = ds.AddMessage(new2)
_, _ = ds.AddMessage(new3)
ds.AddMessage(new1)
ds.AddMessage(old1)
ds.AddMessage(old2)
ds.AddMessage(old3)
ds.AddMessage(new2)
ds.AddMessage(new3)
// Test 4 hour retention
cfg := config.Storage{
RetentionPeriod: 239 * time.Minute,
RetentionSleep: 0,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rs := storage.NewRetentionScanner(cfg, ds)
if err := rs.DoScan(ctx); err != nil {
shutdownChan := make(chan bool)
rs := storage.NewRetentionScanner(cfg, ds, shutdownChan)
if err := rs.DoScan(); err != nil {
t.Error(err)
}
// Delete should not have been called on new messages
for _, m := range []storage.Message{new1, new2, new3} {
if ds.MessageDeleted(m) {
t.Errorf("Expected %v to be present, was deleted", m.ID())
}
}
// Delete should have been called once on old messages
for _, m := range []storage.Message{old1, old2, old3} {
if !ds.MessageDeleted(m) {
@@ -60,7 +53,7 @@ func TestDoRetentionScan(t *testing.T) {
// stubMessage creates a message stub of a specific age
func stubMessage(mailbox string, ageHours int) storage.Message {
return &message.Delivery{
Meta: event.MessageMetadata{
Meta: message.Metadata{
Mailbox: mailbox,
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),

View File

@@ -8,8 +8,7 @@ import (
"net/mail"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/pkg/config"
)
var (
@@ -20,7 +19,7 @@ var (
ErrNotWritable = errors.New("Message not writable")
// Constructors tracks registered storage constructors
Constructors = make(map[string]func(config.Storage, *extension.Host) (Store, error))
Constructors = make(map[string]func(config.Storage) (Store, error))
)
// Store is the interface Inbucket uses to interact with storage implementations.
@@ -49,9 +48,9 @@ type Message interface {
}
// FromConfig creates an instance of the Store based on the provided configuration.
func FromConfig(c config.Storage, extHost *extension.Host) (store Store, err error) {
func FromConfig(c config.Storage) (store Store, err error) {
if cf := Constructors[c.Type]; cf != nil {
return cf(c, extHost)
return cf(c)
}
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
}

View File

@@ -2,7 +2,7 @@ package stringutil
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"net/mail"
"strings"
@@ -13,11 +13,10 @@ import (
func HashMailboxName(mailbox string) string {
h := sha1.New()
if _, err := io.WriteString(h, mailbox); err != nil {
// This should never happen.
// This shouldn't ever happen
return ""
}
return hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("%x", h.Sum(nil))
}
// StringAddress converts an Address to a UTF-8 string.
@@ -75,38 +74,3 @@ func MakePathPrefixer(prefix string) func(string) string {
return prefix + path
}
}
// MatchWithWildcards tests if a "s" string matches a "p" pattern with wildcards (*, ?)
func MatchWithWildcards(p string, s string) bool {
runeInput := []rune(s)
runePattern := []rune(p)
lenInput := len(runeInput)
lenPattern := len(runePattern)
isMatchingMatrix := make([][]bool, lenInput+1)
for i := range isMatchingMatrix {
isMatchingMatrix[i] = make([]bool, lenPattern+1)
}
isMatchingMatrix[0][0] = true
if lenPattern > 0 {
if runePattern[0] == '*' {
isMatchingMatrix[0][1] = true
}
}
for j := 2; j <= lenPattern; j++ {
if runePattern[j-1] == '*' {
isMatchingMatrix[0][j] = isMatchingMatrix[0][j-1]
}
}
for i := 1; i <= lenInput; i++ {
for j := 1; j <= lenPattern; j++ {
if runePattern[j-1] == '*' {
isMatchingMatrix[i][j] = isMatchingMatrix[i-1][j] || isMatchingMatrix[i][j-1]
}
if runePattern[j-1] == '?' || runeInput[i-1] == runePattern[j-1] {
isMatchingMatrix[i][j] = isMatchingMatrix[i-1][j-1]
}
}
}
return isMatchingMatrix[lenInput][lenPattern]
}

View File

@@ -5,18 +5,15 @@ import (
"net/mail"
"testing"
"github.com/inbucket/inbucket/v3/pkg/stringutil"
"github.com/stretchr/testify/assert"
"github.com/inbucket/inbucket/pkg/stringutil"
)
func TestHashMailboxName(t *testing.T) {
want := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
got := stringutil.HashMailboxName("")
assert.Equal(t, want, got, "for empty string")
want = "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
got = stringutil.HashMailboxName("mail")
assert.Equal(t, want, got, "for 'mail'")
want := "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
got := stringutil.HashMailboxName("mail")
if got != want {
t.Errorf("Got %q, want %q", got, want)
}
}
func TestStringAddressList(t *testing.T) {
@@ -79,49 +76,3 @@ func TestMakePathPrefixer(t *testing.T) {
})
}
}
func TestMatchWithWildcards(t *testing.T) {
testCases := []struct {
pattern, input string
want bool
}{
{pattern: "", input: "", want: true},
{pattern: "", input: "qwerty", want: false},
{pattern: "qw*ty", input: "qwerty", want: true},
{pattern: "qw?ty", input: "qwerty", want: false},
{pattern: "qwe*ty", input: "qwerty", want: true},
{pattern: "*erty", input: "qwerty", want: true},
{pattern: "?erty", input: "qwerty", want: false},
{pattern: "?werty", input: "qwerty", want: true},
{pattern: "qwer*", input: "qwerty", want: true},
{pattern: "qwer?", input: "qwerty", want: false},
{pattern: "qwert?", input: "qwerty", want: true},
{pattern: "qw**ty", input: "qwerty", want: true},
{pattern: "qw??ty", input: "qwerty", want: true},
{pattern: "qwe??ty", input: "qwerty", want: false},
{pattern: "**erty", input: "qwerty", want: true},
{pattern: "??erty", input: "qwerty", want: true},
{pattern: "??werty", input: "qwerty", want: false},
{pattern: "qwer**", input: "qwerty", want: true},
{pattern: "qwer??", input: "qwerty", want: true},
{pattern: "qwert??", input: "qwerty", want: false},
{pattern: "q?er?y", input: "qwerty", want: true},
{pattern: "q?r?y", input: "qwerty", want: false},
{pattern: "q*er*y", input: "qwerty", want: true},
{pattern: "q*r*y", input: "qwerty", want: true},
{pattern: "q*?werty", input: "qwerty", want: false},
{pattern: "q*?erty", input: "qwerty", want: true},
{pattern: "q?*werty", input: "qwerty", want: false},
{pattern: "q?*erty", input: "qwerty", want: true},
{pattern: "?*rty", input: "qwerty", want: true},
{pattern: "*?rty", input: "qwerty", want: true},
{pattern: "qwe?*", input: "qwerty", want: true},
{pattern: "qwe*?", input: "qwerty", want: true},
}
for _, tc := range testCases {
got := stringutil.MatchWithWildcards(tc.pattern, tc.input)
if got != tc.want {
t.Errorf("Test %s with pattern %s, Got: %v, want: %v", tc.input, tc.pattern, got, tc.want)
}
}
}

View File

@@ -4,30 +4,27 @@ import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
smtpclient "net/smtp"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/rest"
"github.com/inbucket/inbucket/v3/pkg/rest/client"
"github.com/inbucket/inbucket/v3/pkg/server/smtp"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/storage/mem"
"github.com/inbucket/inbucket/v3/pkg/webui"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/jhillyerd/goldiff"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/suite"
)
const (
@@ -35,129 +32,111 @@ const (
smtpHost = "127.0.0.1:2500"
)
// TODO: Add suites for domain and full addressing modes.
type IntegrationSuite struct {
suite.Suite
stopServer func()
}
func (s *IntegrationSuite) SetupSuite() {
func TestSuite(t *testing.T) {
stopServer, err := startServer()
s.Require().NoError(err)
s.stopServer = stopServer
if err != nil {
t.Fatal(err)
}
defer stopServer()
testCases := []struct {
name string
test func(*testing.T)
}{
{"basic", testBasic},
{"fullname", testFullname},
{"encodedHeader", testEncodedHeader},
}
for _, tc := range testCases {
t.Run(tc.name, tc.test)
}
}
func (s *IntegrationSuite) TearDownSuite() {
s.stopServer()
}
func TestIntegrationSuite(t *testing.T) {
suite.Run(t, new(IntegrationSuite))
}
func (s *IntegrationSuite) TestBasic() {
func testBasic(t *testing.T) {
client, err := client.New(restBaseURL)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("basic.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
s.Require().NoError(err)
s.NotNil(msg)
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(s.T(), got, "testdata", "basic.golden")
goldiff.File(t, got, "testdata", "basic.golden")
}
func (s *IntegrationSuite) TestFullname() {
func testFullname(t *testing.T) {
client, err := client.New(restBaseURL)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("fullname.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
s.Require().NoError(err)
s.NotNil(msg)
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(s.T(), got, "testdata", "fullname.golden")
goldiff.File(t, got, "testdata", "fullname.golden")
}
func (s *IntegrationSuite) TestEncodedHeader() {
func testEncodedHeader(t *testing.T) {
client, err := client.New(restBaseURL)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("encodedheader.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
s.Require().NoError(err)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
s.Require().NoError(err)
s.NotNil(msg)
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(s.T(), got, "testdata", "encodedheader.golden")
}
func (s *IntegrationSuite) TestIPv4Recipient() {
client, err := client.New(restBaseURL)
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"ip4recipient@[192.168.123.123]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
s.Require().NoError(err)
// Confirm receipt.
msg, err := client.GetMessage("ip4recipient", "latest")
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(s.T(), got, "testdata", "no-to-ipv4.golden")
}
func (s *IntegrationSuite) TestIPv6Recipient() {
client, err := client.New(restBaseURL)
s.Require().NoError(err)
from := "fromuser@inbucket.org"
to := []string{"ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]"}
input := readTestData("no-to.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
s.Require().NoError(err)
// Confirm receipt.
msg, err := client.GetMessage("ip6recipient", "latest")
s.Require().NoError(err)
s.NotNil(msg)
// Compare to golden.
got := formatMessage(msg)
goldiff.File(s.T(), got, "testdata", "no-to-ipv6.golden")
goldiff.File(t, got, "testdata", "encodedheader.golden")
}
func formatMessage(m *client.Message) []byte {
@@ -173,46 +152,40 @@ func formatMessage(m *client.Message) []byte {
}
func startServer() (func(), error) {
// TODO Move integration setup into lifecycle.
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
extHost := extension.NewHost()
// Storage setup.
storage.Constructors["memory"] = mem.New
clearEnv()
os.Clearenv()
conf, err := config.Process()
if err != nil {
return nil, err
}
svcCtx, svcCancel := context.WithCancel(context.Background())
store, err := storage.FromConfig(conf.Storage, extHost)
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
if err != nil {
svcCancel()
rootCancel()
return nil, err
}
// TODO Test should not pass with unstarted msghub.
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
msgHub := msghub.New(conf.Web.MonitorHistory, extHost)
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, ExtHost: extHost}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start HTTP server.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webServer := web.NewServer(conf, mmanager, msgHub)
go webServer.Start(svcCtx, func() {})
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
go smtpServer.Start(svcCtx, func() {})
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// TODO Use a readyFunc to determine server readiness.
// TODO Implmement an elegant way to determine server readiness.
time.Sleep(500 * time.Millisecond)
return func() {
// Shut everything down.
svcCancel()
close(shutdownChan)
rootCancel()
smtpServer.Drain()
}, nil
}
@@ -224,28 +197,9 @@ func readTestData(path ...string) []byte {
if err != nil {
panic(err)
}
data, err := io.ReadAll(f)
data, err := ioutil.ReadAll(f)
if err != nil {
panic(err)
}
return data
}
// clearEnv clears environment variables, preserving any that are critical for this OS.
func clearEnv() {
preserve := make(map[string]string)
backup := func(k string) {
preserve[k] = os.Getenv(k)
}
// Backup ciritcal env variables.
if runtime.GOOS == "windows" {
backup("SYSTEMROOT")
}
os.Clearenv()
for k, v := range preserve {
os.Setenv(k, v)
}
}

View File

@@ -3,11 +3,10 @@ package test
import (
"errors"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
)
// ManagerStub is a test stub for message.Manager
@@ -43,14 +42,14 @@ func (m *ManagerStub) GetMessage(mailbox, id string) (*message.Message, error) {
}
// GetMetadata gets all the metadata for the specified mailbox.
func (m *ManagerStub) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) {
if mailbox == "messageserr" {
return nil, errors.New("internal error")
}
messages := m.mailboxes[mailbox]
metas := make([]*event.MessageMetadata, len(messages))
metas := make([]*message.Metadata, len(messages))
for i, msg := range messages {
metas[i] = &msg.MessageMetadata
metas[i] = &msg.Metadata
}
return metas, nil
}
@@ -70,7 +69,7 @@ func (m *ManagerStub) MarkSeen(mailbox, id string) error {
}
for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id {
msg.MessageMetadata.Seen = true
msg.Metadata.Seen = true
return nil
}
}

View File

@@ -3,14 +3,14 @@ package test
import (
"errors"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreStub stubs storage.Store for testing.
type StoreStub struct {
storage.Store
mailboxes map[string][]storage.Message // Stored messages, by mailbox.
deleted map[storage.Message]struct{} // Deleted message references.
mailboxes map[string][]storage.Message
deleted map[storage.Message]struct{}
}
// NewStore creates a new StoreStub.
@@ -25,7 +25,7 @@ func NewStore() *StoreStub {
func (s *StoreStub) AddMessage(m storage.Message) (id string, err error) {
mb := m.Mailbox()
msgs := s.mailboxes[mb]
s.mailboxes[mb] = append(msgs, &MessageStub{Message: m})
s.mailboxes[mb] = append(msgs, m)
return m.ID(), nil
}
@@ -50,72 +50,34 @@ func (s *StoreStub) GetMessages(mailbox string) ([]storage.Message, error) {
return s.mailboxes[mailbox], nil
}
// MarkSeen marks the message as having been seen.
func (s *StoreStub) MarkSeen(mailbox, id string) error {
if mailbox == "messageerr" {
return errors.New("internal error")
}
for _, m := range s.mailboxes[mailbox] {
if m.ID() == id {
if stub, ok := m.(*MessageStub); ok {
stub.seen = true
return nil
}
return errors.New("unexpected type in StoreStub.mailboxes")
}
}
return storage.ErrNotExist
}
// RemoveMessage deletes a message by ID from the specified mailbox.
func (s *StoreStub) RemoveMessage(mailbox, id string) error {
if mb, ok := s.mailboxes[mailbox]; ok {
var removed storage.Message
mb, ok := s.mailboxes[mailbox]
if ok {
var msg storage.Message
for i, m := range mb {
if m.ID() == id {
removed = m
msg = m
s.mailboxes[mailbox] = append(mb[:i], mb[i+1:]...)
break
}
}
if removed != nil {
// Clients will be checking for their original storage.Message, not our wrapper.
if stub, ok := removed.(*MessageStub); ok {
s.deleted[stub.Message] = struct{}{}
return nil
}
return errors.New("unexpected type in StoreStub.mailboxes")
if msg != nil {
s.deleted[msg] = struct{}{}
return nil
}
}
return storage.ErrNotExist
}
// PurgeMessages deletes the contents of a mailbox.
func (s *StoreStub) PurgeMessages(mailbox string) error {
for _, removed := range s.mailboxes[mailbox] {
// Clients will be checking for their original storage.Message, not our wrapper.
if stub, ok := removed.(*MessageStub); ok {
s.deleted[stub.Message] = struct{}{}
} else {
return errors.New("unexpected type in StoreStub.mailboxes")
}
}
s.mailboxes[mailbox] = nil
return nil
}
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
// continues to return true.
func (s *StoreStub) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
for _, msgs := range s.mailboxes {
if !f(msgs) {
for _, v := range s.mailboxes {
if !f(v) {
return nil
}
}
return nil
}
@@ -124,14 +86,3 @@ func (s *StoreStub) MessageDeleted(m storage.Message) bool {
_, ok := s.deleted[m]
return ok
}
// MessageStub wraps a storage.Message with "seen" functionality.
type MessageStub struct {
storage.Message
seen bool
}
// Seen returns true if the message has been marked as seen previously.
func (m *MessageStub) Seen() bool {
return m.seen
}

View File

@@ -1,61 +0,0 @@
package test
import (
"fmt"
"io"
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
)
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
// generated message.
func DeliverToStore(
t *testing.T,
store storage.Store,
mailbox string,
subject string,
date time.Time,
) (string, int64) {
t.Helper()
meta := event.MessageMetadata{
Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: io.NopCloser(strings.NewReader(testMsg)),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
return id, int64(len(testMsg))
}
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
// also checks return error.
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
t.Helper()
msgs, err := s.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) != count {
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
}
return msgs
}

View File

@@ -3,38 +3,25 @@ package test
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/mail"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreFactory returns a new store for the test suite.
type StoreFactory func(
config.Storage, *extension.Host) (store storage.Store, destroy func(), err error)
// storeSuite is passed to each test function; embeds `testing.T` to provide testing primitives.
type storeSuite struct {
*testing.T
store storage.Store
extHost *extension.Host
}
type StoreFactory func(config.Storage) (store storage.Store, destroy func(), err error)
// StoreSuite runs a set of general tests on the provided Store.
func StoreSuite(t *testing.T, factory StoreFactory) {
t.Helper()
testCases := []struct {
name string
test func(storeSuite)
test func(*testing.T, storage.Store)
conf config.Storage
}{
{"metadata", testMetadata, config.Storage{}},
@@ -52,25 +39,18 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
extHost := extension.NewHost()
store, destroy, err := factory(tc.conf, extHost)
store, destroy, err := factory(tc.conf)
if err != nil {
t.Fatal(err)
}
defer destroy()
s := storeSuite{
T: t,
store: store,
extHost: extHost,
}
tc.test(s)
tc.test(t, store)
destroy()
})
}
}
// testMetadata verifies message metadata is stored and retrieved correctly.
func testMetadata(s storeSuite) {
func testMetadata(t *testing.T, store storage.Store) {
mailbox := "testmailbox"
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
to := []*mail.Address{
@@ -81,7 +61,7 @@ func testMetadata(s storeSuite) {
subject := "fantastic test subject line"
content := "doesn't matter"
delivery := &message.Delivery{
Meta: event.MessageMetadata{
Meta: message.Metadata{
// ID and Size will be determined by the Store.
Mailbox: mailbox,
From: from,
@@ -92,52 +72,52 @@ func testMetadata(s storeSuite) {
},
Reader: strings.NewReader(content),
}
id, err := s.store.AddMessage(delivery)
id, err := store.AddMessage(delivery)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if id == "" {
s.Fatal("Expected AddMessage() to return non-empty ID string")
t.Fatal("Expected AddMessage() to return non-empty ID string")
}
// Retrieve and validate the message.
sm, err := s.store.GetMessage(mailbox, id)
sm, err := store.GetMessage(mailbox, id)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if sm.Mailbox() != mailbox {
s.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
t.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
}
if sm.ID() != id {
s.Errorf("got id %q, want: %q", sm.ID(), id)
t.Errorf("got id %q, want: %q", sm.ID(), id)
}
if *sm.From() != *from {
s.Errorf("got from %v, want: %v", sm.From(), from)
t.Errorf("got from %v, want: %v", sm.From(), from)
}
if len(sm.To()) != len(to) {
s.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
t.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
} else {
for i, got := range sm.To() {
if *to[i] != *got {
s.Errorf("got to[%v] %v, want: %v", i, got, to[i])
t.Errorf("got to[%v] %v, want: %v", i, got, to[i])
}
}
}
if !sm.Date().Equal(date) {
s.Errorf("got date %v, want: %v", sm.Date(), date)
t.Errorf("got date %v, want: %v", sm.Date(), date)
}
if sm.Subject() != subject {
s.Errorf("got subject %q, want: %q", sm.Subject(), subject)
t.Errorf("got subject %q, want: %q", sm.Subject(), subject)
}
if sm.Size() != int64(len(content)) {
s.Errorf("got size %v, want: %v", sm.Size(), len(content))
t.Errorf("got size %v, want: %v", sm.Size(), len(content))
}
if sm.Seen() {
s.Errorf("got seen %v, want: false", sm.Seen())
t.Errorf("got seen %v, want: false", sm.Seen())
}
}
// testContent generates some binary content and makes sure it is correctly retrieved.
func testContent(s storeSuite) {
func testContent(t *testing.T, store storage.Store) {
content := make([]byte, 5000)
for i := 0; i < len(content); i++ {
content[i] = byte(i % 256)
@@ -150,7 +130,7 @@ func testContent(s storeSuite) {
date := time.Now()
subject := "fantastic test subject line"
delivery := &message.Delivery{
Meta: event.MessageMetadata{
Meta: message.Metadata{
// ID and Size will be determined by the Store.
Mailbox: mailbox,
From: from,
@@ -160,290 +140,305 @@ func testContent(s storeSuite) {
},
Reader: bytes.NewReader(content),
}
id, err := s.store.AddMessage(delivery)
require.NoError(s, err, "AddMessage() failed")
// Read stored message source.
m, err := s.store.GetMessage(mailbox, id)
require.NoError(s, err, "GetMessage() failed")
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
// Get and check.
m, err := store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
}
r, err := m.Source()
require.NoError(s, err, "Source() failed")
got, err := io.ReadAll(r)
require.NoError(s, err, "failed to read source")
err = r.Close()
require.NoError(s, err, "failed to close source reader")
// Verify source.
if err != nil {
t.Fatal(err)
}
got, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if len(got) != len(content) {
s.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
t.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
}
errors := 0
for i, b := range got {
if b != content[i] {
s.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
t.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
errors++
}
if errors > 5 {
s.Fatalf("Too many content errors, aborting test.")
t.Fatalf("Too many content errors, aborting test.")
break
}
}
}
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
// with a new GetMessages call each cycle.
func testDeliveryOrder(s storeSuite) {
func testDeliveryOrder(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects {
// Check mailbox count.
GetAndCountMessages(s.T, s.store, mailbox, i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
GetAndCountMessages(t, store, mailbox, i)
DeliverToStore(t, store, mailbox, subj, time.Now())
}
// Confirm delivery order.
msgs := GetAndCountMessages(s.T, s.store, mailbox, 5)
msgs := GetAndCountMessages(t, store, mailbox, 5)
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
s.Errorf("Got subject %q, want %q", got, want)
t.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
// the last message sent.
func testLatest(s storeSuite) {
func testLatest(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
DeliverToStore(t, store, mailbox, subj, time.Now())
}
// Confirm latest.
latest, err := s.store.GetMessage(mailbox, "latest")
latest, err := store.GetMessage(mailbox, "latest")
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if latest == nil {
s.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
}
got := latest.Subject()
want := "echo"
if got != want {
s.Errorf("Got subject %q, want %q", got, want)
t.Errorf("Got subject %q, want %q", got, want)
}
}
// testNaming ensures the store does not enforce local part mailbox naming.
func testNaming(s storeSuite) {
DeliverToStore(s.T, s.store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(s.T, s.store, "fred", 0)
GetAndCountMessages(s.T, s.store, "fred@fish.net", 1)
func testNaming(t *testing.T, store storage.Store) {
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(t, store, "fred", 0)
GetAndCountMessages(t, store, "fred@fish.net", 1)
}
// testSize verifies message content size metadata values.
func testSize(s storeSuite) {
func testSize(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects))
sentSizes := make([]int64, len(subjects))
for i, subj := range subjects {
id, size := DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
id, size := DeliverToStore(t, store, mailbox, subj, time.Now())
sentIds[i] = id
sentSizes[i] = size
}
for i, id := range sentIds {
msg, err := s.store.GetMessage(mailbox, id)
msg, err := store.GetMessage(mailbox, id)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
want := sentSizes[i]
got := msg.Size()
if got != want {
s.Errorf("Got size %v, want: %v", got, want)
t.Errorf("Got size %v, want: %v", got, want)
}
}
}
// testSeen verifies a message can be marked as seen.
func testSeen(s storeSuite) {
func testSeen(t *testing.T, store storage.Store) {
mailbox := "lisa"
id1, _ := DeliverToStore(s.T, s.store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(s.T, s.store, mailbox, "hello?", time.Now())
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
// Confirm unseen.
msg, err := s.store.GetMessage(mailbox, id1)
msg, err := store.GetMessage(mailbox, id1)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if msg.Seen() {
s.Errorf("got seen %v, want: false", msg.Seen())
t.Errorf("got seen %v, want: false", msg.Seen())
}
// Mark id1 seen.
err = s.store.MarkSeen(mailbox, id1)
err = store.MarkSeen(mailbox, id1)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
// Verify id1 seen.
msg, err = s.store.GetMessage(mailbox, id1)
msg, err = store.GetMessage(mailbox, id1)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if !msg.Seen() {
s.Errorf("id1 got seen %v, want: true", msg.Seen())
t.Errorf("id1 got seen %v, want: true", msg.Seen())
}
// Verify id2 still unseen.
msg, err = s.store.GetMessage(mailbox, id2)
msg, err = store.GetMessage(mailbox, id2)
if err != nil {
s.Fatal(err)
t.Fatal(err)
}
if msg.Seen() {
s.Errorf("id2 got seen %v, want: false", msg.Seen())
t.Errorf("id2 got seen %v, want: false", msg.Seen())
}
}
// testDelete creates and deletes some messages.
func testDelete(s storeSuite) {
func testDelete(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
DeliverToStore(t, store, mailbox, subj, time.Now())
}
msgs := GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
// Subscribe to events.
eventListener := s.extHost.Events.AfterMessageDeleted.AsyncTestListener("test", 2)
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
// Delete a couple messages.
deleteIDs := []string{msgs[1].ID(), msgs[3].ID()}
for _, id := range deleteIDs {
err := s.store.RemoveMessage(mailbox, id)
require.NoError(s, err)
err := store.RemoveMessage(mailbox, msgs[1].ID())
if err != nil {
t.Fatal(err)
}
err = store.RemoveMessage(mailbox, msgs[3].ID())
if err != nil {
t.Fatal(err)
}
// Confirm deletion.
subjects = []string{"alpha", "charlie", "echo"}
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
s.Errorf("Got subject %q, want %q", got, want)
t.Errorf("Got subject %q, want %q", got, want)
}
}
// Capture events and check correct IDs were emitted.
ev1, err := eventListener()
require.NoError(s, err)
ev2, err := eventListener()
require.NoError(s, err)
eventIDs := []string{ev1.ID, ev2.ID}
for _, id := range deleteIDs {
assert.Contains(s, eventIDs, id)
}
// Try appending one more.
DeliverToStore(s.T, s.store, mailbox, "foxtrot", time.Now())
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
msgs = GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
s.Errorf("Got subject %q, want %q", got, want)
t.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testPurge makes sure mailboxes can be purged.
func testPurge(s storeSuite) {
func testPurge(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
// Subscribe to events.
eventListener := s.extHost.Events.AfterMessageDeleted.AsyncTestListener("test", len(subjects))
// Populate mailbox.
for _, subj := range subjects {
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
DeliverToStore(t, store, mailbox, subj, time.Now())
}
GetAndCountMessages(s.T, s.store, mailbox, len(subjects))
GetAndCountMessages(t, store, mailbox, len(subjects))
// Purge and verify.
err := s.store.PurgeMessages(mailbox)
require.NoError(s, err)
GetAndCountMessages(s.T, s.store, mailbox, 0)
// Confirm events emitted.
gotEvents := []*event.MessageMetadata{}
for range subjects {
ev, err := eventListener()
if err != nil {
s.Error(err)
break
}
gotEvents = append(gotEvents, ev)
err := store.PurgeMessages(mailbox)
if err != nil {
t.Fatal(err)
}
assert.Equal(s, len(subjects), len(gotEvents),
"expected delete event for each message in mailbox")
GetAndCountMessages(t, store, mailbox, 0)
}
// testMsgCap verifies the message cap is enforced.
func testMsgCap(s storeSuite) {
func testMsgCap(t *testing.T, store storage.Store) {
mbCap := 10
mailbox := "captain"
for i := 0; i < 20; i++ {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
msgs, err := s.store.GetMessages(mailbox)
DeliverToStore(t, store, mailbox, subj, time.Now())
msgs, err := store.GetMessages(mailbox)
if err != nil {
s.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) > mbCap {
s.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
t.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
break
}
// Check that the first (oldest) message is correct.
// Check that the first message is correct.
first := i - mbCap + 1
if first < 0 {
first = 0
}
firstSubj := fmt.Sprintf("subject %v", first)
if firstSubj != msgs[0].Subject() {
s.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
t.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
}
}
}
// testNoMsgCap verfies a cap of 0 is not enforced.
func testNoMsgCap(s storeSuite) {
func testNoMsgCap(t *testing.T, store storage.Store) {
mailbox := "captain"
for i := 0; i < 20; i++ {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(s.T, s.store, mailbox, subj, time.Now())
GetAndCountMessages(s.T, s.store, mailbox, i+1)
DeliverToStore(t, store, mailbox, subj, time.Now())
GetAndCountMessages(t, store, mailbox, i+1)
}
}
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
// them.
func testVisitMailboxes(s storeSuite) {
// Deliver 2 test messages to each of 5 mailboxes.
func testVisitMailboxes(t *testing.T, ds storage.Store) {
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
for _, name := range boxes {
DeliverToStore(s.T, s.store, name, "Old Message", time.Now().Add(-24*time.Hour))
DeliverToStore(s.T, s.store, name, "New Message", time.Now())
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
DeliverToStore(t, ds, name, "New Message", time.Now())
}
// Verify message and mailbox counts.
nboxes := 0
err := s.store.VisitMailboxes(func(messages []storage.Message) bool {
nboxes++
name := "unknown"
if len(messages) > 0 {
name = messages[0].Mailbox()
seen := 0
err := ds.VisitMailboxes(func(messages []storage.Message) bool {
seen++
count := len(messages)
if count != 2 {
t.Errorf("got: %v messages, want: 2", count)
}
assert.Len(s, messages, 2, "incorrect message count in mailbox %s", name)
return true
})
require.NoError(s, err, "VisitMailboxes() failed")
assert.Equal(s, 5, nboxes, "visited %v mailboxes, want: 5", nboxes)
if err != nil {
t.Error(err)
}
if seen != 5 {
t.Errorf("saw %v messages in total, want: 5", seen)
}
}
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
// generated message.
func DeliverToStore(
t *testing.T,
store storage.Store,
mailbox string,
subject string,
date time.Time,
) (string, int64) {
t.Helper()
meta := message.Metadata{
Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
return id, int64(len(testMsg))
}
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
// also checks return error.
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
t.Helper()
msgs, err := s.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) != count {
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
}
return msgs
}

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