mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-06-15 09:43:35 +00:00
Merge branch 'release/2.0.0-rc1'
This commit is contained in:
@@ -26,8 +26,12 @@ _testmain.go
|
|||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# our binaries
|
# our binaries
|
||||||
|
/client
|
||||||
|
/client.exe
|
||||||
/inbucket
|
/inbucket
|
||||||
/inbucket.exe
|
/inbucket.exe
|
||||||
/dist/**
|
/dist/**
|
||||||
/cmd/client/client
|
/cmd/client/client
|
||||||
/cmd/client/client.exe
|
/cmd/client/client.exe
|
||||||
|
/cmd/inbucket/inbucket
|
||||||
|
/cmd/inbucket/inbucket.exe
|
||||||
|
|||||||
+26
-6
@@ -1,14 +1,17 @@
|
|||||||
project_name: inbucket
|
project_name: inbucket
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: jhillyerd
|
owner: jhillyerd
|
||||||
name: inbucket
|
name: inbucket
|
||||||
name_template: '{{.Tag}}'
|
name_template: '{{.Tag}}'
|
||||||
|
|
||||||
brew:
|
brew:
|
||||||
commit_author:
|
commit_author:
|
||||||
name: goreleaserbot
|
name: goreleaserbot
|
||||||
email: goreleaser@carlosbecker.com
|
email: goreleaser@carlosbecker.com
|
||||||
install: bin.install ""
|
install: bin.install ""
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: inbucket
|
- binary: inbucket
|
||||||
goos:
|
goos:
|
||||||
@@ -20,9 +23,9 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
main: .
|
main: ./cmd/inbucket
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
- binary: client
|
- binary: inbucket-client
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -34,6 +37,7 @@ builds:
|
|||||||
- "6"
|
- "6"
|
||||||
main: ./cmd/client
|
main: ./cmd/client
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
@@ -46,15 +50,31 @@ archive:
|
|||||||
- LICENSE*
|
- LICENSE*
|
||||||
- README*
|
- README*
|
||||||
- CHANGELOG*
|
- CHANGELOG*
|
||||||
- inbucket.bat
|
|
||||||
- etc/**/*
|
- etc/**/*
|
||||||
- themes/**/*
|
- ui/**/*
|
||||||
fpm:
|
|
||||||
bindir: /usr/local/bin
|
nfpm:
|
||||||
|
vendor: inbucket.org
|
||||||
|
homepage: https://www.inbucket.org/
|
||||||
|
maintainer: github@hillyerd.com
|
||||||
|
description: All-in-one disposable webmail service.
|
||||||
|
license: MIT
|
||||||
|
formats:
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
|
files:
|
||||||
|
"ui/**/*": "/usr/local/share/inbucket/ui"
|
||||||
|
config_files:
|
||||||
|
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||||
|
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: SNAPSHOT-{{ .Commit }}
|
name_template: SNAPSHOT-{{ .Commit }}
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||||
|
|
||||||
dist: dist
|
dist: dist
|
||||||
|
|
||||||
sign:
|
sign:
|
||||||
artifacts: none
|
artifacts: none
|
||||||
|
|||||||
+3
-3
@@ -2,14 +2,14 @@ language: go
|
|||||||
sudo: false
|
sudo: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- DEPLOY_WITH_MAJOR="1.9"
|
- DEPLOY_WITH_MAJOR="1.10"
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- go get github.com/golang/lint/golint
|
- go get github.com/golang/lint/golint
|
||||||
|
- make deps
|
||||||
|
|
||||||
go:
|
go:
|
||||||
- 1.9.x
|
- "1.10.x"
|
||||||
- "1.10"
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
provider: script
|
provider: script
|
||||||
|
|||||||
+44
-1
@@ -4,10 +4,52 @@ Change Log
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [2.0.0-rc1] - 2018-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inbucket is now configured using environment variables instead of a config
|
||||||
|
file.
|
||||||
|
- In-memory storage option, best for small installations and desktops. Will be
|
||||||
|
used by default.
|
||||||
|
- Storage type is now displayed on Status page.
|
||||||
|
- Store size is now calculated during retention scan and displayed on the Status
|
||||||
|
page.
|
||||||
|
- Debian `.deb` package generation to release process.
|
||||||
|
- RedHat `.rpm` package generation to release process.
|
||||||
|
- Message seen flag in REST and Web UI so you can see which messages have
|
||||||
|
already been read.
|
||||||
|
- Recipient domain accept policy; Inbucket can now reject mail to specific
|
||||||
|
domains.
|
||||||
|
- Configurable support for identifying a mailbox by full email address instead
|
||||||
|
of just the local part (username).
|
||||||
|
- Friendly URL support: `<inbucket-url>/<mailbox>` will redirect your browser to
|
||||||
|
that mailbox.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Massive refactor of back-end code. Inbucket should now be both easier and
|
||||||
|
more enjoyable to work on.
|
||||||
|
- Changes to file storage format, will require pre-2.0 mail store directories to
|
||||||
|
be deleted.
|
||||||
|
- Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap`
|
||||||
|
directory.
|
||||||
|
- Docker build:
|
||||||
|
- Uses the same default ports as other builds; smtp:2500 http:9000 pop3:1100
|
||||||
|
- Uses volume `/config` for `greeting.html`
|
||||||
|
- Uses volume `/storage` for mail storage
|
||||||
|
- Log output is now structured, and will be output as JSON with the `-logjson`
|
||||||
|
flag; which is enabled by default for the Docker container.
|
||||||
|
- SMTP and POP3 network tracing is no longer logged regardless of level, but can
|
||||||
|
be sent to stdout via `-netdebug` flag.
|
||||||
|
- Replaced store/nostore config variables with a storage policy that mirrors the
|
||||||
|
domain accept policy.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- No longer support SIGHUP or log file rotation.
|
||||||
|
|
||||||
|
|
||||||
## [v1.3.1] - 2018-03-10
|
## [v1.3.1] - 2018-03-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Adding additional locking during message delivery to prevent race condition
|
- Adding additional locking during message delivery to prevent race condition
|
||||||
that could lose messages.
|
that could lose messages.
|
||||||
|
|
||||||
@@ -112,6 +154,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
specific message.
|
specific message.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||||
|
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
||||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||||
|
|||||||
+37
-19
@@ -1,25 +1,43 @@
|
|||||||
# Docker build file for Inbucket, see https://www.docker.io/
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
# Inbucket website: http://www.inbucket.org/
|
|
||||||
|
|
||||||
FROM golang:1.9-alpine
|
# Build
|
||||||
MAINTAINER James Hillyerd, @jameshillyerd
|
FROM golang:1.10-alpine as builder
|
||||||
|
RUN apk add --no-cache --virtual .build-deps git make
|
||||||
|
WORKDIR /go/src/github.com/jhillyerd/inbucket
|
||||||
|
COPY . .
|
||||||
|
ENV CGO_ENABLED 0
|
||||||
|
RUN make clean deps
|
||||||
|
RUN go build -o inbucket \
|
||||||
|
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||||
|
-v ./cmd/inbucket
|
||||||
|
|
||||||
# Configuration (WORKDIR doesn't support env vars)
|
# Run in minimal image
|
||||||
ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket
|
FROM alpine:3.7
|
||||||
ENV INBUCKET_HOME /opt/inbucket
|
ENV SRC /go/src/github.com/jhillyerd/inbucket
|
||||||
WORKDIR $INBUCKET_HOME
|
WORKDIR /opt/inbucket
|
||||||
ENTRYPOINT ["/con/context/start-inbucket.sh"]
|
RUN mkdir bin defaults ui
|
||||||
CMD ["/con/configuration/inbucket.conf"]
|
COPY --from=builder $SRC/inbucket bin
|
||||||
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
|
COPY ui ui
|
||||||
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ENV INBUCKET_SMTP_DISCARDDOMAINS bitbucket.local
|
||||||
|
ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||||
|
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||||
|
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||||
|
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||||
|
ENV INBUCKET_STORAGE_TYPE file
|
||||||
|
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||||
|
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||||
|
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
||||||
|
|
||||||
# Ports: SMTP, HTTP, POP3
|
# Ports: SMTP, HTTP, POP3
|
||||||
EXPOSE 10025 10080 10110
|
EXPOSE 2500 9000 1100
|
||||||
|
|
||||||
# Persistent Volumes, following convention at:
|
# Persistent Volumes
|
||||||
# https://github.com/docker/docker/issues/9277
|
VOLUME /config
|
||||||
# NOTE /con/context is also used, not exposed by default
|
VOLUME /storage
|
||||||
VOLUME /con/configuration
|
|
||||||
VOLUME /con/data
|
|
||||||
|
|
||||||
# Build Inbucket
|
ENTRYPOINT ["/start-inbucket.sh"]
|
||||||
COPY . $INBUCKET_SRC/
|
CMD ["-logjson"]
|
||||||
RUN "$INBUCKET_SRC/etc/docker/install.sh"
|
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
PKG := inbucket
|
SHELL = /bin/sh
|
||||||
SHELL := /bin/sh
|
|
||||||
|
|
||||||
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
||||||
PKGS := $$(go list ./... | grep -v /vendor/)
|
PKGS := $(shell go list ./... | grep -v /vendor/)
|
||||||
|
|
||||||
.PHONY: all build clean fmt install lint simplify test
|
.PHONY: all build clean fmt lint reflex simplify test
|
||||||
|
|
||||||
all: test lint build
|
commands = client inbucket
|
||||||
|
|
||||||
|
all: clean test lint build
|
||||||
|
|
||||||
|
$(commands): %: cmd/%
|
||||||
|
go build ./$<
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean
|
go clean $(PKGS)
|
||||||
|
rm -f $(commands)
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
go get -t ./...
|
go get -t ./...
|
||||||
|
|
||||||
build: clean deps
|
build: $(commands)
|
||||||
go build
|
|
||||||
|
|
||||||
install: build
|
test:
|
||||||
go install
|
|
||||||
|
|
||||||
test: clean deps
|
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -31,5 +33,8 @@ simplify:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||||
@golint -set_exit_status $${PKGS}
|
@golint -set_exit_status $(PKGS)
|
||||||
@go vet $${PKGS}
|
@go vet $(PKGS)
|
||||||
|
|
||||||
|
reflex:
|
||||||
|
reflex -r '\.go$$' -- sh -c 'echo; date; echo; go test ./... && echo ALL PASS'
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ to contribute code to the project check out [CONTRIBUTING.md].
|
|||||||
|
|
||||||
## Homebrew Tap
|
## Homebrew Tap
|
||||||
|
|
||||||
|
(currently broken, being tracked in [issue
|
||||||
|
#68](https://github.com/jhillyerd/inbucket/issues/68))
|
||||||
|
|
||||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||||
see the `README.md` there for installation instructions.
|
see the `README.md` there for installation instructions.
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ You will need a functioning [Go installation][Google Go] for this to work.
|
|||||||
|
|
||||||
Grab the Inbucket source code and compile the daemon:
|
Grab the Inbucket source code and compile the daemon:
|
||||||
|
|
||||||
go get -v github.com/jhillyerd/inbucket
|
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
|
||||||
|
|
||||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
Unix and OS X machines as is. Launch the daemon:
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listCmd struct {
|
type listCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type matchCmd struct {
|
type matchCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mboxCmd struct {
|
type mboxCmd struct {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// main is the inbucket daemon launcher
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"expvar"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/pop3"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage/file"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// version contains the build version number, populated during linking.
|
||||||
|
version = "undefined"
|
||||||
|
|
||||||
|
// date contains the build date, populated during linking.
|
||||||
|
date = "undefined"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Server uptime for status page.
|
||||||
|
startTime := time.Now()
|
||||||
|
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
||||||
|
return time.Since(startTime) / time.Second
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Goroutine count for status page.
|
||||||
|
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||||
|
return runtime.NumGoroutine()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Register storage implementations.
|
||||||
|
storage.Constructors["file"] = file.New
|
||||||
|
storage.Constructors["memory"] = mem.New
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Command line flags.
|
||||||
|
help := flag.Bool("help", false, "Displays help on flags and env variables.")
|
||||||
|
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.")
|
||||||
|
netdebug := flag.Bool("netdebug", false, "Dump SMTP & POP3 network traffic to stdout.")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: inbucket [options]")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
if *help {
|
||||||
|
flag.Usage()
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
config.Usage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Process configuration.
|
||||||
|
config.Version = version
|
||||||
|
config.BuildDate = date
|
||||||
|
conf, err := config.Process()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *netdebug {
|
||||||
|
conf.POP3.Debug = true
|
||||||
|
conf.SMTP.Debug = true
|
||||||
|
}
|
||||||
|
// Logger setup.
|
||||||
|
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Log error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
startupLog := log.With().Str("phase", "startup").Logger()
|
||||||
|
// Setup signal handler.
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
// Initialize logging.
|
||||||
|
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||||
|
Msg("Inbucket starting")
|
||||||
|
// Write pidfile if requested.
|
||||||
|
if *pidfile != "" {
|
||||||
|
pidf, err := os.Create(*pidfile)
|
||||||
|
if err != nil {
|
||||||
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to create pidfile")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
||||||
|
if err := pidf.Close(); err != nil {
|
||||||
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Configure internal services.
|
||||||
|
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||||
|
shutdownChan := make(chan bool)
|
||||||
|
store, err := storage.FromConfig(conf.Storage)
|
||||||
|
if err != nil {
|
||||||
|
removePIDFile(*pidfile)
|
||||||
|
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
|
||||||
|
}
|
||||||
|
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||||
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||||
|
// Start Retention scanner.
|
||||||
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||||
|
retentionScanner.Start()
|
||||||
|
// Start HTTP server.
|
||||||
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
|
rest.SetupRoutes(web.Router)
|
||||||
|
webui.SetupRoutes(web.Router)
|
||||||
|
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:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-sigChan:
|
||||||
|
switch sig {
|
||||||
|
case syscall.SIGINT:
|
||||||
|
// Shutdown requested
|
||||||
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
|
||||||
|
Msg("Received SIGINT, shutting down")
|
||||||
|
close(shutdownChan)
|
||||||
|
case syscall.SIGTERM:
|
||||||
|
// Shutdown requested
|
||||||
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
||||||
|
Msg("Received SIGTERM, shutting down")
|
||||||
|
close(shutdownChan)
|
||||||
|
}
|
||||||
|
case <-shutdownChan:
|
||||||
|
rootCancel()
|
||||||
|
break signalLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wait for active connections to finish.
|
||||||
|
go timedExit(*pidfile)
|
||||||
|
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) (close func(), err error) {
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
|
case "info":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
case "warn":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||||
|
case "error":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
|
||||||
|
}
|
||||||
|
close = func() {}
|
||||||
|
var w io.Writer
|
||||||
|
color := true
|
||||||
|
switch logfile {
|
||||||
|
case "stderr":
|
||||||
|
w = os.Stderr
|
||||||
|
case "stdout":
|
||||||
|
w = os.Stdout
|
||||||
|
default:
|
||||||
|
logf, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bw := bufio.NewWriter(logf)
|
||||||
|
w = bw
|
||||||
|
color = false
|
||||||
|
close = func() {
|
||||||
|
_ = bw.Flush()
|
||||||
|
_ = logf.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w = zerolog.SyncWriter(w)
|
||||||
|
if json {
|
||||||
|
log.Logger = log.Output(w)
|
||||||
|
return close, nil
|
||||||
|
}
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||||
|
Out: w,
|
||||||
|
NoColor: !color,
|
||||||
|
})
|
||||||
|
return close, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePIDFile removes the PID file if created.
|
||||||
|
func removePIDFile(pidfile string) {
|
||||||
|
if pidfile != "" {
|
||||||
|
if err := os.Remove(pidfile); err != nil {
|
||||||
|
log.Error().Str("phase", "shutdown").Err(err).Str("path", pidfile).
|
||||||
|
Msg("Failed to remove pidfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds.
|
||||||
|
func timedExit(pidfile string) {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
removePIDFile(pidfile)
|
||||||
|
log.Error().Str("phase", "shutdown").Msg("Clean shutdown took too long, forcing exit")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/robfig/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SMTPConfig contains the SMTP server configuration - not using pointers so that we can pass around
|
|
||||||
// copies of the object safely.
|
|
||||||
type SMTPConfig struct {
|
|
||||||
IP4address net.IP
|
|
||||||
IP4port int
|
|
||||||
Domain string
|
|
||||||
DomainNoStore string
|
|
||||||
MaxRecipients int
|
|
||||||
MaxIdleSeconds int
|
|
||||||
MaxMessageBytes int
|
|
||||||
StoreMessages bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// POP3Config contains the POP3 server configuration
|
|
||||||
type POP3Config struct {
|
|
||||||
IP4address net.IP
|
|
||||||
IP4port int
|
|
||||||
Domain string
|
|
||||||
MaxIdleSeconds int
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebConfig contains the HTTP server configuration
|
|
||||||
type WebConfig struct {
|
|
||||||
IP4address net.IP
|
|
||||||
IP4port int
|
|
||||||
TemplateDir string
|
|
||||||
TemplateCache bool
|
|
||||||
PublicDir string
|
|
||||||
GreetingFile string
|
|
||||||
MailboxPrompt string
|
|
||||||
CookieAuthKey string
|
|
||||||
MonitorVisible bool
|
|
||||||
MonitorHistory int
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataStoreConfig contains the mail store configuration
|
|
||||||
type DataStoreConfig struct {
|
|
||||||
Path string
|
|
||||||
RetentionMinutes int
|
|
||||||
RetentionSleep int
|
|
||||||
MailboxMsgCap int
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
missingErrorFmt = "[%v] missing required option %q"
|
|
||||||
parseErrorFmt = "[%v] option %q error: %v"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Version of this build, set by main
|
|
||||||
Version = ""
|
|
||||||
|
|
||||||
// BuildDate for this build, set by main
|
|
||||||
BuildDate = ""
|
|
||||||
|
|
||||||
// Config is our global robfig/config object
|
|
||||||
Config *config.Config
|
|
||||||
logLevel string
|
|
||||||
|
|
||||||
// Parsed specific configs
|
|
||||||
smtpConfig = &SMTPConfig{}
|
|
||||||
pop3Config = &POP3Config{}
|
|
||||||
webConfig = &WebConfig{}
|
|
||||||
dataStoreConfig = &DataStoreConfig{}
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetSMTPConfig returns a copy of the SmtpConfig object
|
|
||||||
func GetSMTPConfig() SMTPConfig {
|
|
||||||
return *smtpConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPOP3Config returns a copy of the Pop3Config object
|
|
||||||
func GetPOP3Config() POP3Config {
|
|
||||||
return *pop3Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWebConfig returns a copy of the WebConfig object
|
|
||||||
func GetWebConfig() WebConfig {
|
|
||||||
return *webConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDataStoreConfig returns a copy of the DataStoreConfig object
|
|
||||||
func GetDataStoreConfig() DataStoreConfig {
|
|
||||||
return *dataStoreConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogLevel returns the configured log level
|
|
||||||
func GetLogLevel() string {
|
|
||||||
return logLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads the specified configuration file into inbucket.Config and performs validations
|
|
||||||
// on it.
|
|
||||||
func LoadConfig(filename string) error {
|
|
||||||
var err error
|
|
||||||
Config, err = config.ReadDefault(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Validation error messages
|
|
||||||
messages := make([]string, 0)
|
|
||||||
// Validate sections
|
|
||||||
for _, s := range []string{"logging", "smtp", "pop3", "web", "datastore"} {
|
|
||||||
if !Config.HasSection(s) {
|
|
||||||
messages = append(messages,
|
|
||||||
fmt.Sprintf("Config section [%v] is required", s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return immediately if config is missing entire sections
|
|
||||||
if len(messages) > 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
|
||||||
for _, m := range messages {
|
|
||||||
fmt.Fprintln(os.Stderr, " -", m)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Failed to validate configuration")
|
|
||||||
}
|
|
||||||
// Load string config options
|
|
||||||
stringOptions := []struct {
|
|
||||||
section string
|
|
||||||
name string
|
|
||||||
target *string
|
|
||||||
required bool
|
|
||||||
}{
|
|
||||||
{"logging", "level", &logLevel, true},
|
|
||||||
{"smtp", "domain", &smtpConfig.Domain, true},
|
|
||||||
{"smtp", "domain.nostore", &smtpConfig.DomainNoStore, false},
|
|
||||||
{"pop3", "domain", &pop3Config.Domain, true},
|
|
||||||
{"web", "template.dir", &webConfig.TemplateDir, true},
|
|
||||||
{"web", "public.dir", &webConfig.PublicDir, true},
|
|
||||||
{"web", "greeting.file", &webConfig.GreetingFile, true},
|
|
||||||
{"web", "mailbox.prompt", &webConfig.MailboxPrompt, false},
|
|
||||||
{"web", "cookie.auth.key", &webConfig.CookieAuthKey, false},
|
|
||||||
{"datastore", "path", &dataStoreConfig.Path, true},
|
|
||||||
}
|
|
||||||
for _, opt := range stringOptions {
|
|
||||||
str, err := Config.String(opt.section, opt.name)
|
|
||||||
if Config.HasOption(opt.section, opt.name) && err != nil {
|
|
||||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if str == "" && opt.required {
|
|
||||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
|
||||||
}
|
|
||||||
*opt.target = str
|
|
||||||
}
|
|
||||||
// Load boolean config options
|
|
||||||
boolOptions := []struct {
|
|
||||||
section string
|
|
||||||
name string
|
|
||||||
target *bool
|
|
||||||
required bool
|
|
||||||
}{
|
|
||||||
{"smtp", "store.messages", &smtpConfig.StoreMessages, true},
|
|
||||||
{"web", "template.cache", &webConfig.TemplateCache, true},
|
|
||||||
{"web", "monitor.visible", &webConfig.MonitorVisible, true},
|
|
||||||
}
|
|
||||||
for _, opt := range boolOptions {
|
|
||||||
if Config.HasOption(opt.section, opt.name) {
|
|
||||||
flag, err := Config.Bool(opt.section, opt.name)
|
|
||||||
if err != nil {
|
|
||||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
|
||||||
}
|
|
||||||
*opt.target = flag
|
|
||||||
} else {
|
|
||||||
if opt.required {
|
|
||||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Load integer config options
|
|
||||||
intOptions := []struct {
|
|
||||||
section string
|
|
||||||
name string
|
|
||||||
target *int
|
|
||||||
required bool
|
|
||||||
}{
|
|
||||||
{"smtp", "ip4.port", &smtpConfig.IP4port, true},
|
|
||||||
{"smtp", "max.recipients", &smtpConfig.MaxRecipients, true},
|
|
||||||
{"smtp", "max.idle.seconds", &smtpConfig.MaxIdleSeconds, true},
|
|
||||||
{"smtp", "max.message.bytes", &smtpConfig.MaxMessageBytes, true},
|
|
||||||
{"pop3", "ip4.port", &pop3Config.IP4port, true},
|
|
||||||
{"pop3", "max.idle.seconds", &pop3Config.MaxIdleSeconds, true},
|
|
||||||
{"web", "ip4.port", &webConfig.IP4port, true},
|
|
||||||
{"web", "monitor.history", &webConfig.MonitorHistory, true},
|
|
||||||
{"datastore", "retention.minutes", &dataStoreConfig.RetentionMinutes, true},
|
|
||||||
{"datastore", "retention.sleep.millis", &dataStoreConfig.RetentionSleep, true},
|
|
||||||
{"datastore", "mailbox.message.cap", &dataStoreConfig.MailboxMsgCap, true},
|
|
||||||
}
|
|
||||||
for _, opt := range intOptions {
|
|
||||||
if Config.HasOption(opt.section, opt.name) {
|
|
||||||
num, err := Config.Int(opt.section, opt.name)
|
|
||||||
if err != nil {
|
|
||||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
|
||||||
}
|
|
||||||
*opt.target = num
|
|
||||||
} else {
|
|
||||||
if opt.required {
|
|
||||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Load IP address config options
|
|
||||||
ipOptions := []struct {
|
|
||||||
section string
|
|
||||||
name string
|
|
||||||
target *net.IP
|
|
||||||
required bool
|
|
||||||
}{
|
|
||||||
{"smtp", "ip4.address", &smtpConfig.IP4address, true},
|
|
||||||
{"pop3", "ip4.address", &pop3Config.IP4address, true},
|
|
||||||
{"web", "ip4.address", &webConfig.IP4address, true},
|
|
||||||
}
|
|
||||||
for _, opt := range ipOptions {
|
|
||||||
if Config.HasOption(opt.section, opt.name) {
|
|
||||||
str, err := Config.String(opt.section, opt.name)
|
|
||||||
if err != nil {
|
|
||||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr := net.ParseIP(str)
|
|
||||||
if addr == nil {
|
|
||||||
messages = append(messages,
|
|
||||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q", opt.section, opt.name, str))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr = addr.To4()
|
|
||||||
if addr == nil {
|
|
||||||
messages = append(messages,
|
|
||||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q not IPv4!",
|
|
||||||
opt.section, opt.name, str))
|
|
||||||
}
|
|
||||||
*opt.target = addr
|
|
||||||
} else {
|
|
||||||
if opt.required {
|
|
||||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validate log level
|
|
||||||
switch strings.ToUpper(logLevel) {
|
|
||||||
case "":
|
|
||||||
// Missing was already reported
|
|
||||||
case "TRACE", "INFO", "WARN", "ERROR":
|
|
||||||
default:
|
|
||||||
messages = append(messages,
|
|
||||||
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
|
|
||||||
}
|
|
||||||
// Print messages and return error if any validations failed
|
|
||||||
if len(messages) > 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
|
||||||
sort.Strings(messages)
|
|
||||||
for _, m := range messages {
|
|
||||||
fmt.Fprintln(os.Stderr, " -", m)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Failed to validate configuration")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Package datastore contains implementation independent datastore logic
|
|
||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/mail"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrNotExist indicates the requested message does not exist
|
|
||||||
ErrNotExist = errors.New("Message does not exist")
|
|
||||||
|
|
||||||
// ErrNotWritable indicates the message is closed; no longer writable
|
|
||||||
ErrNotWritable = errors.New("Message not writable")
|
|
||||||
)
|
|
||||||
|
|
||||||
// DataStore is an interface to get Mailboxes stored in Inbucket
|
|
||||||
type DataStore interface {
|
|
||||||
MailboxFor(emailAddress string) (Mailbox, error)
|
|
||||||
AllMailboxes() ([]Mailbox, error)
|
|
||||||
// LockFor is a temporary hack to fix #77 until Datastore revamp
|
|
||||||
LockFor(emailAddress string) (*sync.RWMutex, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mailbox is an interface to get and manipulate messages in a DataStore
|
|
||||||
type Mailbox interface {
|
|
||||||
GetMessages() ([]Message, error)
|
|
||||||
GetMessage(id string) (Message, error)
|
|
||||||
Purge() error
|
|
||||||
NewMessage() (Message, error)
|
|
||||||
Name() string
|
|
||||||
String() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message is an interface for a single message in a Mailbox
|
|
||||||
type Message interface {
|
|
||||||
ID() string
|
|
||||||
From() string
|
|
||||||
To() []string
|
|
||||||
Date() time.Time
|
|
||||||
Subject() string
|
|
||||||
RawReader() (reader io.ReadCloser, err error)
|
|
||||||
ReadHeader() (msg *mail.Message, err error)
|
|
||||||
ReadBody() (body *enmime.Envelope, err error)
|
|
||||||
ReadRaw() (raw *string, err error)
|
|
||||||
Append(data []byte) error
|
|
||||||
Close() error
|
|
||||||
Delete() error
|
|
||||||
String() string
|
|
||||||
Size() int64
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HashLock [4096]sync.RWMutex
|
|
||||||
|
|
||||||
func (h *HashLock) Get(hash string) *sync.RWMutex {
|
|
||||||
if len(hash) < 3 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i, err := strconv.ParseInt(hash[0:3], 16, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &h[i]
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDoRetentionScan(t *testing.T) {
|
|
||||||
// Create mock objects
|
|
||||||
mds := &MockDataStore{}
|
|
||||||
|
|
||||||
mb1 := &MockMailbox{}
|
|
||||||
mb2 := &MockMailbox{}
|
|
||||||
mb3 := &MockMailbox{}
|
|
||||||
|
|
||||||
// Mockup some different aged messages (num is in hours)
|
|
||||||
new1 := mockMessage(0)
|
|
||||||
new2 := mockMessage(1)
|
|
||||||
new3 := mockMessage(2)
|
|
||||||
old1 := mockMessage(4)
|
|
||||||
old2 := mockMessage(12)
|
|
||||||
old3 := mockMessage(24)
|
|
||||||
|
|
||||||
// First it should ask for all mailboxes
|
|
||||||
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
|
|
||||||
|
|
||||||
// Then for all messages on each box
|
|
||||||
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
|
|
||||||
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
|
|
||||||
mb3.On("GetMessages").Return([]Message{new3}, nil)
|
|
||||||
|
|
||||||
// Test 4 hour retention
|
|
||||||
rs := &RetentionScanner{
|
|
||||||
ds: mds,
|
|
||||||
retentionPeriod: 4*time.Hour - time.Minute,
|
|
||||||
retentionSleep: 0,
|
|
||||||
}
|
|
||||||
if err := rs.doScan(); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check our assertions
|
|
||||||
mds.AssertExpectations(t)
|
|
||||||
mb1.AssertExpectations(t)
|
|
||||||
mb2.AssertExpectations(t)
|
|
||||||
mb3.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Delete should not have been called on new messages
|
|
||||||
new1.AssertNotCalled(t, "Delete")
|
|
||||||
new2.AssertNotCalled(t, "Delete")
|
|
||||||
new3.AssertNotCalled(t, "Delete")
|
|
||||||
|
|
||||||
// Delete should have been called once on old messages
|
|
||||||
old1.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
old2.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
old3.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a MockMessage of a specific age
|
|
||||||
func mockMessage(ageHours int) *MockMessage {
|
|
||||||
msg := &MockMessage{}
|
|
||||||
msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
|
|
||||||
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
|
|
||||||
msg.On("Delete").Return(nil)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/mail"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockDataStore is a shared mock for unit testing
|
|
||||||
type MockDataStore struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxFor mock function
|
|
||||||
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
|
|
||||||
args := m.Called(name)
|
|
||||||
return args.Get(0).(Mailbox), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllMailboxes mock function
|
|
||||||
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]Mailbox), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDataStore) LockFor(name string) (*sync.RWMutex, error) {
|
|
||||||
return &sync.RWMutex{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockMailbox is a shared mock for unit testing
|
|
||||||
type MockMailbox struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages mock function
|
|
||||||
func (m *MockMailbox) GetMessages() ([]Message, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessage mock function
|
|
||||||
func (m *MockMailbox) GetMessage(id string) (Message, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
return args.Get(0).(Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge mock function
|
|
||||||
func (m *MockMailbox) Purge() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage mock function
|
|
||||||
func (m *MockMailbox) NewMessage() (Message, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name mock function
|
|
||||||
func (m *MockMailbox) Name() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String mock function
|
|
||||||
func (m *MockMailbox) String() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockMessage is a shared mock for unit testing
|
|
||||||
type MockMessage struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID mock function
|
|
||||||
func (m *MockMessage) ID() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// From mock function
|
|
||||||
func (m *MockMessage) From() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To mock function
|
|
||||||
func (m *MockMessage) To() []string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date mock function
|
|
||||||
func (m *MockMessage) Date() time.Time {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(time.Time)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject mock function
|
|
||||||
func (m *MockMessage) Subject() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHeader mock function
|
|
||||||
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*mail.Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBody mock function
|
|
||||||
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*enmime.Envelope), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRaw mock function
|
|
||||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*string), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RawReader mock function
|
|
||||||
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size mock function
|
|
||||||
func (m *MockMessage) Size() int64 {
|
|
||||||
args := m.Called()
|
|
||||||
return int64(args.Int(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append mock function
|
|
||||||
func (m *MockMessage) Append(data []byte) error {
|
|
||||||
// []byte arg seems to mess up testify/mock
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close mock function
|
|
||||||
func (m *MockMessage) Close() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete mock function
|
|
||||||
func (m *MockMessage) Delete() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String mock function
|
|
||||||
func (m *MockMessage) String() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
# Inbucket Configuration
|
||||||
|
|
||||||
|
Inbucket is configured via environment variables. Most options have a
|
||||||
|
reasonable default, but it is likely you will need to change some to suite your
|
||||||
|
desired use cases.
|
||||||
|
|
||||||
|
Running `inbucket -help` will yield a condensed summary of the environment
|
||||||
|
variables it supports:
|
||||||
|
|
||||||
|
KEY DEFAULT DESCRIPTION
|
||||||
|
INBUCKET_LOGLEVEL info debug, info, warn, or error
|
||||||
|
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
|
||||||
|
INBUCKET_SMTP_MAXMESSAGEBYTES 10240000 Maximum message size
|
||||||
|
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_DEFAULTSTORE true Store all mail by default?
|
||||||
|
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||||
|
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||||
|
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
|
||||||
|
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
|
||||||
|
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||||
|
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||||
|
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||||
|
INBUCKET_WEB_UIDIR ui User interface dir
|
||||||
|
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||||
|
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
|
||||||
|
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
|
||||||
|
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
||||||
|
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||||
|
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||||
|
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
|
||||||
|
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
||||||
|
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
||||||
|
INBUCKET_STORAGE_RETENTIONSLEEP 50ms Duration to sleep between mailboxes
|
||||||
|
INBUCKET_STORAGE_MAILBOXMSGCAP 500 Maximum messages per mailbox
|
||||||
|
|
||||||
|
The following documentation will describe each of these in more detail.
|
||||||
|
|
||||||
|
|
||||||
|
## Global
|
||||||
|
|
||||||
|
### Log Level
|
||||||
|
|
||||||
|
`INBUCKET_LOGLEVEL`
|
||||||
|
|
||||||
|
This setting controls the verbosity of log output. A small desktop installation
|
||||||
|
should probably select `info`, but a busy shared installation would be better
|
||||||
|
off with `warn` or `error`.
|
||||||
|
|
||||||
|
- Default: `info`
|
||||||
|
- Values: one of `debug`, `info`, `warn`, or `error`
|
||||||
|
|
||||||
|
### Mailbox Naming
|
||||||
|
|
||||||
|
`INBUCKET_MAILBOXNAMING`
|
||||||
|
|
||||||
|
The mailbox naming setting determines the name of a mailbox for an incoming
|
||||||
|
message, and thus where it must be retrieved from later.
|
||||||
|
|
||||||
|
#### `local` ensures the domain is removed, such that:
|
||||||
|
|
||||||
|
- `james@inbucket.org` is stored in `james`
|
||||||
|
- `james+spam@inbucket.org` is stored in `james`
|
||||||
|
|
||||||
|
#### `full` retains the domain as part of the name, such that:
|
||||||
|
|
||||||
|
- `james@inbucket.org` is stored in `james@inbucket.org`
|
||||||
|
- `james+spam@inbucket.org` is stored in `james@inbucket.org`
|
||||||
|
|
||||||
|
Prior to the addition of the mailbox naming setting, Inbucket always operated in
|
||||||
|
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||||
|
incorporated into the mailbox name.
|
||||||
|
|
||||||
|
- Default: `local`
|
||||||
|
- Values: one of `local` or `full`
|
||||||
|
|
||||||
|
|
||||||
|
## SMTP
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the SMTP server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
25 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:2500`
|
||||||
|
|
||||||
|
### Greeting Domain
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DOMAIN`
|
||||||
|
|
||||||
|
The domain used in the SMTP greeting:
|
||||||
|
|
||||||
|
220 domain Inbucket SMTP ready
|
||||||
|
|
||||||
|
Most SMTP clients appear to ignore this value.
|
||||||
|
|
||||||
|
- Default: `inbucket`
|
||||||
|
|
||||||
|
### Maximum Recipients
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_MAXRECIPIENTS`
|
||||||
|
|
||||||
|
Maximum number of recipients allowed (SMTP `RCPT TO` phase). If you are testing
|
||||||
|
a mailing list server, you may need to increase this value. For comparison, the
|
||||||
|
Postfix SMTP server uses a default of 1000, it would be unwise to exceed this.
|
||||||
|
|
||||||
|
- Default: `200`
|
||||||
|
|
||||||
|
### Maximum Message Size
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_MAXMESSAGEBYTES`
|
||||||
|
|
||||||
|
Maximum allowable size of a message (including headers) in bytes. Messages
|
||||||
|
exceeding this size will be rejected during the SMTP `DATA` phase.
|
||||||
|
|
||||||
|
- Default: `10240000` (10MB)
|
||||||
|
|
||||||
|
### Default Recipient Accept Policy
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DEFAULTACCEPT`
|
||||||
|
|
||||||
|
If true, Inbucket will accept mail to any domain unless present in the reject
|
||||||
|
domains list. If false, recipients will be rejected unless their domain is
|
||||||
|
present in the accept domains list.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Accepted Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_ACCEPTDOMAINS`
|
||||||
|
|
||||||
|
List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
|
||||||
|
has no effect when true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `localhost,mysite.org`
|
||||||
|
|
||||||
|
### Rejected Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_REJECTDOMAINS`
|
||||||
|
|
||||||
|
List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
|
||||||
|
has no effect when false.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `reject.com,gmail.com`
|
||||||
|
|
||||||
|
### Default Recipient Store Policy
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DEFAULTSTORE`
|
||||||
|
|
||||||
|
If true, Inbucket will store mail sent to any domain unless present in the
|
||||||
|
discard domains list. If false, messages will be discarded unless their domain
|
||||||
|
is present in the store domains list.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Stored Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_STOREDOMAINS`
|
||||||
|
|
||||||
|
List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
|
||||||
|
has no effect when true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `localhost,mysite.org`
|
||||||
|
|
||||||
|
### Discarded Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DISCARDDOMAINS`
|
||||||
|
|
||||||
|
Mail sent to these domains will not be stored by Inbucket. This is helpful if
|
||||||
|
you are load or soak testing a service, and do not plan to inspect the resulting
|
||||||
|
emails. Messages sent to a domain other than this will be stored normally.
|
||||||
|
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `recycle.com,loadtest.org`
|
||||||
|
|
||||||
|
### Network Idle Timeout
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_TIMEOUT`
|
||||||
|
|
||||||
|
Delay before closing an idle SMTP connection. The SMTP RFC recommends 300
|
||||||
|
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
|
||||||
|
to the public internet.
|
||||||
|
|
||||||
|
- Default: `300s`
|
||||||
|
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||||
|
|
||||||
|
|
||||||
|
## POP3
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_POP3_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the POP3 server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
110 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:1100`
|
||||||
|
|
||||||
|
### Greeting Domain
|
||||||
|
|
||||||
|
`INBUCKET_POP3_DOMAIN`
|
||||||
|
|
||||||
|
The domain used in the POP3 greeting:
|
||||||
|
|
||||||
|
+OK Inbucket POP3 server ready <26641.1522000423@domain>
|
||||||
|
|
||||||
|
Most POP3 clients appear to ignore this value.
|
||||||
|
|
||||||
|
- Default: `inbucket`
|
||||||
|
|
||||||
|
### Network Idle Timeout
|
||||||
|
|
||||||
|
`INBUCKET_POP3_TIMEOUT`
|
||||||
|
|
||||||
|
Delay before closing an idle POP3 connection. The POP3 RFC recommends 600
|
||||||
|
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
|
||||||
|
to the public internet.
|
||||||
|
|
||||||
|
- Default: `600s`
|
||||||
|
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||||
|
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_WEB_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the HTTP server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
80 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:9000`
|
||||||
|
|
||||||
|
### UI Directory
|
||||||
|
|
||||||
|
`INBUCKET_WEB_UIDIR`
|
||||||
|
|
||||||
|
This directory contains the templates and static assets for the web user
|
||||||
|
interface. You will need to change this if the current working directory
|
||||||
|
doesn't contain the `ui` directory at startup.
|
||||||
|
|
||||||
|
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||||
|
static assets from the `static` sub-directory.
|
||||||
|
|
||||||
|
- Default: `ui`
|
||||||
|
- Values: Operating system specific path syntax
|
||||||
|
|
||||||
|
### Greeting HTML File
|
||||||
|
|
||||||
|
`INBUCKET_WEB_GREETINGFILE`
|
||||||
|
|
||||||
|
The content of the greeting file will be injected into the front page of
|
||||||
|
Inbucket. It can be used to instruct users on how to send mail into your
|
||||||
|
Inbucket installation, as well as link to REST documentation, etc.
|
||||||
|
|
||||||
|
- Default: `ui/greeting.html`
|
||||||
|
|
||||||
|
### Template Caching
|
||||||
|
|
||||||
|
`INBUCKET_WEB_TEMPLATECACHE`
|
||||||
|
|
||||||
|
Tells Inbucket to cache parsed template files. This should be left as default
|
||||||
|
unless you are a developer working on the Inbucket web interface.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Mailbox Prompt
|
||||||
|
|
||||||
|
`INBUCKET_WEB_MAILBOXPROMPT`
|
||||||
|
|
||||||
|
Text prompt displayed to the right of the mailbox name input field in the web
|
||||||
|
interface. Can be used to nudge your users into typing just the mailbox name
|
||||||
|
instead of an entire email address.
|
||||||
|
|
||||||
|
Set to an empty string to hide the prompt.
|
||||||
|
|
||||||
|
- Default: `@inbucket`
|
||||||
|
|
||||||
|
### Cookie Authentication Key
|
||||||
|
|
||||||
|
`INBUCKET_WEB_COOKIEAUTHKEY`
|
||||||
|
|
||||||
|
Inbucket stores session information in an encrypted browser cookie. Unless
|
||||||
|
specified, Inbucket generates a random key at startup. The only notable data
|
||||||
|
stored in a user session is the list of recently accessed mailboxes.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Value: Text string, no particular format required
|
||||||
|
|
||||||
|
### Monitor Visible
|
||||||
|
|
||||||
|
`INBUCKET_WEB_MONITORVISIBLE`
|
||||||
|
|
||||||
|
If true, the Monitor tab will be available, allowing users to observe all
|
||||||
|
messages received by Inbucket as they arrive. Disabling the monitor facilitates
|
||||||
|
security through obscurity.
|
||||||
|
|
||||||
|
This setting has no impact on the availability of the underlying WebSocket,
|
||||||
|
which may be used by other parts of the Inbucket interface or continuous
|
||||||
|
integration tests.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Monitor History
|
||||||
|
|
||||||
|
`INBUCKET_WEB_MONITORHISTORY`
|
||||||
|
|
||||||
|
The number of messages to remember on the *server* for new Monitor clients.
|
||||||
|
Does not impact the amount of *new* messages displayed by the Monitor.
|
||||||
|
Increasing this has no appreciable impact on memory use, but may slow down the
|
||||||
|
Monitor user interface.
|
||||||
|
|
||||||
|
This setting has the same effect on the amount of messages available via
|
||||||
|
WebSocket.
|
||||||
|
|
||||||
|
Setting to 0 will disable the monitor, but will probably break new mail
|
||||||
|
notifications in the web interface when I finally get around to implementing
|
||||||
|
them.
|
||||||
|
|
||||||
|
- Default: `30`
|
||||||
|
- Values: Integer greater than or equal to 0
|
||||||
|
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_TYPE`
|
||||||
|
|
||||||
|
Selects the storage implementation to use. Currently Inbucket supports two:
|
||||||
|
|
||||||
|
- `file`: stores messages as individual files in a nested directory structure
|
||||||
|
based on the hash of the mailbox name. Each mailbox also includes an index
|
||||||
|
file to speed up enumeration of the mailbox contents.
|
||||||
|
- `memory`: stores messages in RAM, they will be lost if Inbucket is restarted,
|
||||||
|
or crashes, etc.
|
||||||
|
|
||||||
|
File storage is recommended for larger/shared installations. Memory is better
|
||||||
|
suited to desktop or continuous integration test use cases.
|
||||||
|
|
||||||
|
- Default: `memory`
|
||||||
|
- Values: `file` or `memory`
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_PARAMS`
|
||||||
|
|
||||||
|
Parameters specific to the storage type selected. Formatted as a comma
|
||||||
|
separated list of key:value pairs.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Examples: `maxkb=10240` or `path=/tmp/inbucket`
|
||||||
|
|
||||||
|
#### `file` type parameters
|
||||||
|
|
||||||
|
- `path`: Operating system specific path to the directory where mail should be
|
||||||
|
stored.
|
||||||
|
|
||||||
|
#### `memory` type parameters
|
||||||
|
|
||||||
|
- `maxkb`: Maximum size of the mail store in kilobytes. The oldest messages in
|
||||||
|
the store will be deleted to enforce the limit. In-memory storage has some
|
||||||
|
overhead, for now it is recommended to set this to half the total amount of
|
||||||
|
memory you are willing to allocate to Inbucket.
|
||||||
|
|
||||||
|
### Retention Period
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_RETENTIONPERIOD`
|
||||||
|
|
||||||
|
If set, Inbucket will scan the contents of its mail store once per minute,
|
||||||
|
removing messages older than this. This will be enforced regardless of the type
|
||||||
|
of storage configured.
|
||||||
|
|
||||||
|
- Default: `24h`
|
||||||
|
- Values: Duration ending in `m` for minutes, `h` for hours. Should be
|
||||||
|
significantly longer than one minute, or `0` to disable.
|
||||||
|
|
||||||
|
### Retention Sleep
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_RETENTIONSLEEP`
|
||||||
|
|
||||||
|
Duration to sleep between scanning each mailbox for expired messages.
|
||||||
|
Increasing this number will reduce disk thrashing, but extend the length of time
|
||||||
|
required to complete a scan of the entire mail store.
|
||||||
|
|
||||||
|
This delay is still enforced for memory stores, but could be reduced from the
|
||||||
|
default. Setting to `0` may degrade performance of HTTP/SMTP/POP3 services.
|
||||||
|
|
||||||
|
- Default: `50ms`
|
||||||
|
- Values: Duration ending in `ms` for milliseconds, `s` for seconds
|
||||||
|
|
||||||
|
### Per Mailbox Message Cap
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_MAILBOXMSGCAP`
|
||||||
|
|
||||||
|
Maximum messages allowed in a single mailbox, exceeding this will cause older
|
||||||
|
messages to be deleted from the mailbox.
|
||||||
|
|
||||||
|
- Default: `500`
|
||||||
|
- Values: Positive integer, or `0` to disable
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# dev-start.sh
|
||||||
|
# description: Developer friendly Inbucket configuration
|
||||||
|
|
||||||
|
export INBUCKET_LOGLEVEL="debug"
|
||||||
|
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||||
|
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||||
|
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||||
|
export INBUCKET_STORAGE_TYPE="file"
|
||||||
|
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||||
|
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
||||||
|
|
||||||
|
if ! test -x ./inbucket; then
|
||||||
|
echo "$PWD/inbucket not found/executable!" >&2
|
||||||
|
echo "Run this script from the inbucket root directory after running make" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ./inbucket $*
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
# devel.conf
|
|
||||||
# Sample development configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
default.domain=inbucket.local
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=TRACE
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=30
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=20480000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=false
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/tmp/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=0
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=100
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# Configuration for Inbucket inside of Docker
|
|
||||||
#
|
|
||||||
# These should be reasonable defaults for a production install of Inbucket
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=/opt/inbucket
|
|
||||||
default.domain=inbucket.local
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=10025
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=10110
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=10080
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=/con/configuration/greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/con/data
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=4320
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=300
|
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
# start-inbucket.sh
|
# start-inbucket.sh
|
||||||
# description: start inbucket (runs within a docker container)
|
# description: start inbucket (runs within a docker container)
|
||||||
|
|
||||||
|
INBUCKET_HOME="/opt/inbucket"
|
||||||
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
||||||
CONF_TARGET="/con/configuration"
|
CONF_TARGET="/config"
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ install_default_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_default_config "inbucket.conf"
|
|
||||||
install_default_config "greeting.html"
|
install_default_config "greeting.html"
|
||||||
|
|
||||||
exec "$INBUCKET_HOME/bin/inbucket" $*
|
exec "$INBUCKET_HOME/bin/inbucket" $*
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# install.sh
|
|
||||||
# description: Build, test, and install Inbucket. Should be executed inside a Docker container.
|
|
||||||
|
|
||||||
set -eo pipefail
|
|
||||||
|
|
||||||
installdir="$INBUCKET_HOME"
|
|
||||||
srcdir="$INBUCKET_SRC"
|
|
||||||
bindir="$installdir/bin"
|
|
||||||
defaultsdir="$installdir/defaults"
|
|
||||||
contextdir="/con/context"
|
|
||||||
|
|
||||||
echo "### Installing OS Build Dependencies"
|
|
||||||
apk add --no-cache --virtual .build-deps git
|
|
||||||
|
|
||||||
# Setup
|
|
||||||
export GOBIN="$bindir"
|
|
||||||
cd "$srcdir"
|
|
||||||
# Fetch tags for describe
|
|
||||||
git fetch -t
|
|
||||||
builddate="$(date -Iseconds)"
|
|
||||||
buildver="$(git describe --tags --always)"
|
|
||||||
|
|
||||||
# Build
|
|
||||||
go clean
|
|
||||||
echo "### Fetching Dependencies"
|
|
||||||
go get -t -v ./...
|
|
||||||
|
|
||||||
echo "### Testing Inbucket"
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
echo "### Building Inbucket"
|
|
||||||
go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$builddate'" -v .
|
|
||||||
|
|
||||||
echo "### Installing Inbucket"
|
|
||||||
set -x
|
|
||||||
mkdir -p "$bindir"
|
|
||||||
install inbucket "$bindir"
|
|
||||||
mkdir -p "$contextdir"
|
|
||||||
install etc/docker/defaults/start-inbucket.sh "$contextdir"
|
|
||||||
cp -r themes "$installdir/"
|
|
||||||
mkdir -p "$defaultsdir"
|
|
||||||
cp etc/docker/defaults/inbucket.conf "$defaultsdir"
|
|
||||||
cp etc/docker/defaults/greeting.html "$defaultsdir"
|
|
||||||
set +x
|
|
||||||
|
|
||||||
echo "### Removing OS Build Dependencies"
|
|
||||||
apk del .build-deps
|
|
||||||
|
|
||||||
echo "### Removing $GOPATH"
|
|
||||||
rm -rf "$GOPATH"
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# homebrew inbucket configuration
|
|
||||||
# {{}} values will be replaced during installation
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
default.domain=inbucket.local
|
|
||||||
themes.dir={{HOMEBREW_PREFIX}}/share/inbucket/themes
|
|
||||||
datastore.dir={{HOMEBREW_PREFIX}}/var/inbucket/datastore
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(themes.dir)s/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(themes.dir)s/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=%(themes.dir)s/greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=%(datastore.dir)s
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=10080
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=100
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# Sample inbucket configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
default.domain=inbucket.local
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/tmp/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=500
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Inbucket Disposable Email Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=daemon
|
||||||
|
Group=daemon
|
||||||
|
PermissionsStartOnly=true
|
||||||
|
|
||||||
|
Environment=INBUCKET_LOGLEVEL=warn
|
||||||
|
Environment=INBUCKET_SMTP_ADDR=0.0.0.0:2500
|
||||||
|
Environment=INBUCKET_POP3_ADDR=0.0.0.0:1100
|
||||||
|
Environment=INBUCKET_WEB_ADDR=0.0.0.0:9000
|
||||||
|
Environment=INBUCKET_WEB_UIDIR=/usr/local/share/inbucket/ui
|
||||||
|
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
|
||||||
|
Environment=INBUCKET_STORAGE_TYPE=file
|
||||||
|
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/local/bin/inbucket
|
||||||
|
|
||||||
|
ExecStartPre=/bin/mkdir -p /var/local/inbucket
|
||||||
|
ExecStartPre=/bin/chown daemon:daemon /var/local/inbucket
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/inbucket
|
||||||
|
|
||||||
|
# Give SMTP connections time to drain
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=mixed
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Please see the RedHat installation guide on our website:
|
|
||||||
|
|
||||||
http://www.inbucket.org/installation/redhat.html
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Inbucket reverse proxy, Apache will forward requests from port 80
|
|
||||||
# to Inbucket's built in web server on port 9000
|
|
||||||
#
|
|
||||||
# Replace SERVERFQDN with your servers fully qualified domain name
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName SERVERFQDN
|
|
||||||
ProxyRequests off
|
|
||||||
|
|
||||||
<Proxy *>
|
|
||||||
Order allow,deny
|
|
||||||
Allow from all
|
|
||||||
</Proxy>
|
|
||||||
|
|
||||||
RewriteRule ^/$ http://SERVERFQDN:9000
|
|
||||||
ProxyPass / http://SERVERFQDN:9000/
|
|
||||||
ProxyPassReverse / http://SERVERFQDN:9000/
|
|
||||||
</VirtualHost>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
#
|
|
||||||
# inbucket Inbucket email testing service
|
|
||||||
#
|
|
||||||
# chkconfig: 2345 80 30
|
|
||||||
# description: Inbucket is a disposable email service for testing email
|
|
||||||
# functionality of other applications.
|
|
||||||
# processname: inbucket
|
|
||||||
# pidfile: /var/run/inbucket/inbucket.pid
|
|
||||||
|
|
||||||
### BEGIN INIT INFO
|
|
||||||
# Provides: Inbucket service
|
|
||||||
# Required-Start: $local_fs $network $remote_fs
|
|
||||||
# Required-Stop: $local_fs $network $remote_fs
|
|
||||||
# Default-Start: 2 3 4 5
|
|
||||||
# Default-Stop: 0 1 6
|
|
||||||
# Short-Description: start and stop inbucket
|
|
||||||
# Description: Inbucket is a disposable email service for testing email
|
|
||||||
# functionality of other applications.
|
|
||||||
# moves mail from one machine to another.
|
|
||||||
### END INIT INFO
|
|
||||||
|
|
||||||
# Source function library.
|
|
||||||
. /etc/rc.d/init.d/functions
|
|
||||||
|
|
||||||
# Source networking configuration.
|
|
||||||
. /etc/sysconfig/network
|
|
||||||
|
|
||||||
RETVAL=0
|
|
||||||
program=/opt/inbucket/inbucket
|
|
||||||
prog=${program##*/}
|
|
||||||
config=/etc/opt/inbucket.conf
|
|
||||||
runas=inbucket
|
|
||||||
|
|
||||||
lockfile=/var/lock/subsys/$prog
|
|
||||||
pidfile=/var/run/$prog/$prog.pid
|
|
||||||
logfile=/var/log/$prog.log
|
|
||||||
|
|
||||||
conf_check() {
|
|
||||||
[ -x $program ] || exit 5
|
|
||||||
[ -f $config ] || exit 6
|
|
||||||
}
|
|
||||||
|
|
||||||
perms_check() {
|
|
||||||
mkdir -p /var/run/$prog
|
|
||||||
chown $runas: /var/run/$prog
|
|
||||||
touch $logfile
|
|
||||||
chown $runas: $logfile
|
|
||||||
# Allow bind to ports under 1024
|
|
||||||
setcap 'cap_net_bind_service=+ep' $program
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
# Check that networking is up.
|
|
||||||
[ ${NETWORKING} = "no" ] && exit 1
|
|
||||||
# Check config sanity
|
|
||||||
conf_check
|
|
||||||
perms_check
|
|
||||||
# Start daemon
|
|
||||||
echo -n $"Starting $prog: "
|
|
||||||
daemon --user $runas --pidfile $pidfile $program \
|
|
||||||
-pidfile $pidfile -logfile $logfile $config \&
|
|
||||||
RETVAL=$?
|
|
||||||
[ $RETVAL -eq 0 ] && touch $lockfile
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
conf_check
|
|
||||||
# Stop daemon
|
|
||||||
echo -n $"Shutting down $prog: "
|
|
||||||
killproc -p "$pidfile" -d 15 "$program"
|
|
||||||
RETVAL=$?
|
|
||||||
[ $RETVAL -eq 0 ] && rm -f $lockfile $pidfile
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
reload() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
echo -n $"Reloading $prog: "
|
|
||||||
killproc -p "$pidfile" "$program" -HUP
|
|
||||||
RETVAL=$?
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
# See how we were called.
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
[ -e $lockfile ] && exit 0
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
[ -e $lockfile ] || exit 0
|
|
||||||
stop
|
|
||||||
;;
|
|
||||||
reload)
|
|
||||||
[ -e $lockfile ] || exit 0
|
|
||||||
reload
|
|
||||||
;;
|
|
||||||
restart|force-reload)
|
|
||||||
stop
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
status -p $pidfile -l $(basename $lockfile) $prog
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo $"Usage: $0 {start|stop|restart|status}"
|
|
||||||
exit 2
|
|
||||||
esac
|
|
||||||
|
|
||||||
exit $?
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/var/log/inbucket.log {
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 0644 inbucket inbucket
|
|
||||||
postrotate
|
|
||||||
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Inbucket Disposable Email Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=inbucket
|
|
||||||
Group=inbucket
|
|
||||||
|
|
||||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
|
||||||
|
|
||||||
# Re-open log file after rotation
|
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
|
||||||
|
|
||||||
# Give SMTP connections time to drain
|
|
||||||
TimeoutStopSec=20
|
|
||||||
KillMode=mixed
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,3 +0,0 @@
|
|||||||
Please see the Ubuntu installation guide on our website:
|
|
||||||
|
|
||||||
http://www.inbucket.org/installation/ubuntu.html
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# inbucket - disposable email service
|
|
||||||
#
|
|
||||||
# Inbucket is an SMTP server with a web interface for testing application
|
|
||||||
# functionality
|
|
||||||
|
|
||||||
description "inbucket - disposable email service"
|
|
||||||
author "http://jhillyerd.github.com/inbucket"
|
|
||||||
|
|
||||||
start on (local-filesystems and net-device-up IFACE!=lo)
|
|
||||||
stop on runlevel [!2345]
|
|
||||||
|
|
||||||
env program=/opt/inbucket/inbucket
|
|
||||||
env config=/etc/opt/inbucket.conf
|
|
||||||
env logfile=/var/log/inbucket.log
|
|
||||||
env runas=inbucket
|
|
||||||
|
|
||||||
# Give SMTP connections time to drain
|
|
||||||
kill timeout 20
|
|
||||||
|
|
||||||
pre-start script
|
|
||||||
[ -x $program ]
|
|
||||||
[ -r $config ]
|
|
||||||
touch $logfile
|
|
||||||
chown $runas: $logfile
|
|
||||||
# Allow bind to ports under 1024
|
|
||||||
setcap 'cap_net_bind_service=+ep' $program
|
|
||||||
end script
|
|
||||||
|
|
||||||
exec start-stop-daemon --start --chuid $runas --exec $program -- -logfile $logfile $config
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/var/log/inbucket.log {
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 0644 inbucket inbucket
|
|
||||||
postrotate
|
|
||||||
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Inbucket Disposable Email Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=inbucket
|
|
||||||
Group=inbucket
|
|
||||||
|
|
||||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
|
||||||
|
|
||||||
# Re-open log file after rotation
|
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
|
||||||
|
|
||||||
# Give SMTP connections time to drain
|
|
||||||
TimeoutStopSec=20
|
|
||||||
KillMode=mixed
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# Sample inbucket configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=/opt/inbucket
|
|
||||||
default.domain=inbucket.local
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=25
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=110
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=80
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/var/opt/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=500
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# win-sample.conf
|
|
||||||
# Sample inbucket configuration for Windows
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
default.domain=inbucket.local
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=%(default.domain)s
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=bootstrap
|
|
||||||
|
|
||||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
|
||||||
# empty or comment out to hide the prompt.
|
|
||||||
mailbox.prompt=@inbucket
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s\themes\%(theme)s\templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s\themes\%(theme)s\public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
|
||||||
# installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s\themes\greeting.html
|
|
||||||
|
|
||||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
|
||||||
# If this is left unset, Inbucket will generate a random key at startup
|
|
||||||
# and previous sessions will be invalidated.
|
|
||||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
|
||||||
|
|
||||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
|
||||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
|
||||||
# on the availability of the underlying WebSocket.
|
|
||||||
monitor.visible=true
|
|
||||||
|
|
||||||
# How many historical message headers should be cached for display by new
|
|
||||||
# monitor connections. It does not limit the number of messages displayed by
|
|
||||||
# the browser once the monitor is open; all freshly received messages will be
|
|
||||||
# appended to the on screen list. This setting also affects the underlying
|
|
||||||
# API/WebSocket.
|
|
||||||
monitor.history=30
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=.\inbucket-data
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
|
|
||||||
# Maximum number of messages we will store in a single mailbox. If this
|
|
||||||
# number is exceeded, the oldest message in the box will be deleted each
|
|
||||||
# time a new message is received for it.
|
|
||||||
mailbox.message.cap=500
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/mail"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileMessage implements Message and contains a little bit of data about a
|
|
||||||
// particular email message, and methods to retrieve the rest of it from disk.
|
|
||||||
type FileMessage struct {
|
|
||||||
mailbox *FileMailbox
|
|
||||||
// Stored in GOB
|
|
||||||
Fid string
|
|
||||||
Fdate time.Time
|
|
||||||
Ffrom string
|
|
||||||
Fto []string
|
|
||||||
Fsubject string
|
|
||||||
Fsize int64
|
|
||||||
// These are for creating new messages only
|
|
||||||
writable bool
|
|
||||||
writerFile *os.File
|
|
||||||
writer *bufio.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
|
|
||||||
// It will also delete messages over messageCap if configured.
|
|
||||||
func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
|
|
||||||
// Load index
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old messages over messageCap
|
|
||||||
if mb.store.messageCap > 0 {
|
|
||||||
for len(mb.messages) >= mb.store.messageCap {
|
|
||||||
log.Infof("Mailbox %q over configured message cap", mb.name)
|
|
||||||
if err := mb.messages[0].Delete(); err != nil {
|
|
||||||
log.Errorf("Error deleting message: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
date := time.Now()
|
|
||||||
id := generateID(date)
|
|
||||||
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID gets the ID of the Message
|
|
||||||
func (m *FileMessage) ID() string {
|
|
||||||
return m.Fid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date returns the date/time this Message was received by Inbucket
|
|
||||||
func (m *FileMessage) Date() time.Time {
|
|
||||||
return m.Fdate
|
|
||||||
}
|
|
||||||
|
|
||||||
// From returns the value of the Message From header
|
|
||||||
func (m *FileMessage) From() string {
|
|
||||||
return m.Ffrom
|
|
||||||
}
|
|
||||||
|
|
||||||
// To returns the value of the Message To header
|
|
||||||
func (m *FileMessage) To() []string {
|
|
||||||
return m.Fto
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject returns the value of the Message Subject header
|
|
||||||
func (m *FileMessage) Subject() string {
|
|
||||||
return m.Fsubject
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns a string in the form: "Subject()" from From()
|
|
||||||
func (m *FileMessage) String() string {
|
|
||||||
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size returns the size of the Message on disk in bytes
|
|
||||||
func (m *FileMessage) Size() int64 {
|
|
||||||
return m.Fsize
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *FileMessage) rawPath() string {
|
|
||||||
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
|
|
||||||
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
return mail.ReadMessage(reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
|
|
||||||
func (m *FileMessage) ReadBody() (body *enmime.Envelope, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
mime, err := enmime.ReadEnvelope(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RawReader opens the .raw portion of a Message as an io.ReadCloser
|
|
||||||
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRaw opens the .raw portion of a Message and returns it as a string
|
|
||||||
func (m *FileMessage) ReadRaw() (raw *string, err error) {
|
|
||||||
reader, err := m.RawReader()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := reader.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
bodyString := string(bodyBytes)
|
|
||||||
return &bodyString, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append data to a newly opened Message, this will fail on a pre-existing Message and
|
|
||||||
// after Close() is called.
|
|
||||||
func (m *FileMessage) Append(data []byte) error {
|
|
||||||
// Prevent Appending to a pre-existing Message
|
|
||||||
if !m.writable {
|
|
||||||
return datastore.ErrNotWritable
|
|
||||||
}
|
|
||||||
// Open file for writing if we haven't yet
|
|
||||||
if m.writer == nil {
|
|
||||||
// Ensure mailbox directory exists
|
|
||||||
if err := m.mailbox.createDir(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
file, err := os.Create(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
// Set writable false just in case something calls me a million times
|
|
||||||
m.writable = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.writerFile = file
|
|
||||||
m.writer = bufio.NewWriter(file)
|
|
||||||
}
|
|
||||||
_, err := m.writer.Write(data)
|
|
||||||
m.Fsize += int64(len(data))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close this Message for writing - no more data may be Appended. Close() will also
|
|
||||||
// trigger the creation of the .gob file.
|
|
||||||
func (m *FileMessage) Close() error {
|
|
||||||
// nil out the writer fields so they can't be used
|
|
||||||
writer := m.writer
|
|
||||||
writerFile := m.writerFile
|
|
||||||
m.writer = nil
|
|
||||||
m.writerFile = nil
|
|
||||||
|
|
||||||
if writer != nil {
|
|
||||||
if err := writer.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if writerFile != nil {
|
|
||||||
if err := writerFile.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch headers
|
|
||||||
body, err := m.ReadBody()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only public fields are stored in gob, hence starting with capital F
|
|
||||||
// Parse From address
|
|
||||||
if address, err := mail.ParseAddress(body.GetHeader("From")); err == nil {
|
|
||||||
m.Ffrom = address.String()
|
|
||||||
} else {
|
|
||||||
m.Ffrom = body.GetHeader("From")
|
|
||||||
}
|
|
||||||
m.Fsubject = body.GetHeader("Subject")
|
|
||||||
|
|
||||||
// Turn the To header into a slice
|
|
||||||
if addresses, err := body.AddressList("To"); err == nil {
|
|
||||||
for _, a := range addresses {
|
|
||||||
m.Fto = append(m.Fto, a.String())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.Fto = []string{body.GetHeader("To")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the index before adding our message
|
|
||||||
err = m.mailbox.readIndex()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Made it this far without errors, add it to the index
|
|
||||||
m.mailbox.messages = append(m.mailbox.messages, m)
|
|
||||||
return m.mailbox.writeIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete this Message from disk by removing it from the index and deleting the
|
|
||||||
// raw files.
|
|
||||||
func (m *FileMessage) Delete() error {
|
|
||||||
messages := m.mailbox.messages
|
|
||||||
for i, mm := range messages {
|
|
||||||
if m == mm {
|
|
||||||
// Slice around message we are deleting
|
|
||||||
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := m.mailbox.writeIndex(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(m.mailbox.messages) == 0 {
|
|
||||||
// This was the last message, thus writeIndex() has removed the entire
|
|
||||||
// directory; we don't need to delete the raw file.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// There are still messages in the index
|
|
||||||
log.Tracef("Deleting %v", m.rawPath())
|
|
||||||
return os.Remove(m.rawPath())
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
"github.com/jhillyerd/inbucket/stringutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Name of index file in each mailbox
|
|
||||||
const indexFileName = "index.gob"
|
|
||||||
|
|
||||||
var (
|
|
||||||
// indexMx is locked while reading/writing an index file
|
|
||||||
//
|
|
||||||
// NOTE: This is a bottleneck because it's a single lock even if we have a
|
|
||||||
// million index files
|
|
||||||
indexMx = new(sync.RWMutex)
|
|
||||||
|
|
||||||
// dirMx is locked while creating/removing directories
|
|
||||||
dirMx = new(sync.Mutex)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
countChannel = make(chan int, 10)
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Start generator
|
|
||||||
go countGenerator(countChannel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populates the channel with numbers
|
|
||||||
func countGenerator(c chan int) {
|
|
||||||
for i := 0; true; i = (i + 1) % 10000 {
|
|
||||||
c <- i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileDataStore implements DataStore aand is the root of the mail storage
|
|
||||||
// hiearchy. It provides access to Mailbox objects
|
|
||||||
type FileDataStore struct {
|
|
||||||
hashLock datastore.HashLock
|
|
||||||
path string
|
|
||||||
mailPath string
|
|
||||||
messageCap int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileDataStore creates a new DataStore object using the specified path
|
|
||||||
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
|
|
||||||
path := cfg.Path
|
|
||||||
if path == "" {
|
|
||||||
log.Errorf("No value configured for datastore path")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
mailPath := filepath.Join(path, "mail")
|
|
||||||
if _, err := os.Stat(mailPath); err != nil {
|
|
||||||
// Mail datastore does not yet exist
|
|
||||||
if err = os.MkdirAll(mailPath, 0770); err != nil {
|
|
||||||
log.Errorf("Error creating dir %q: %v", mailPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &FileDataStore{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
|
|
||||||
// construct it's path.
|
|
||||||
func DefaultFileDataStore() datastore.DataStore {
|
|
||||||
cfg := config.GetDataStoreConfig()
|
|
||||||
return NewFileDataStore(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
|
|
||||||
// does not exist, it will attempt to create it.
|
|
||||||
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
|
|
||||||
name, err := stringutil.ParseMailboxName(emailAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dir := stringutil.HashMailboxName(name)
|
|
||||||
s1 := dir[0:3]
|
|
||||||
s2 := dir[0:6]
|
|
||||||
path := filepath.Join(ds.mailPath, s1, s2, dir)
|
|
||||||
indexPath := filepath.Join(path, indexFileName)
|
|
||||||
|
|
||||||
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
|
|
||||||
indexPath: indexPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllMailboxes returns a slice with all Mailboxes
|
|
||||||
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
|
|
||||||
mailboxes := make([]datastore.Mailbox, 0, 100)
|
|
||||||
infos1, err := ioutil.ReadDir(ds.mailPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over level 1 directories
|
|
||||||
for _, inf1 := range infos1 {
|
|
||||||
if inf1.IsDir() {
|
|
||||||
l1 := inf1.Name()
|
|
||||||
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over level 2 directories
|
|
||||||
for _, inf2 := range infos2 {
|
|
||||||
if inf2.IsDir() {
|
|
||||||
l2 := inf2.Name()
|
|
||||||
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over mailboxes
|
|
||||||
for _, inf3 := range infos3 {
|
|
||||||
if inf3.IsDir() {
|
|
||||||
mbdir := inf3.Name()
|
|
||||||
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
|
|
||||||
idx := filepath.Join(mbpath, indexFileName)
|
|
||||||
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
|
|
||||||
indexPath: idx}
|
|
||||||
mailboxes = append(mailboxes, mb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mailboxes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ds *FileDataStore) LockFor(emailAddress string) (*sync.RWMutex, error) {
|
|
||||||
name, err := stringutil.ParseMailboxName(emailAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
hash := stringutil.HashMailboxName(name)
|
|
||||||
return ds.hashLock.Get(hash), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileMailbox implements Mailbox, manages the mail for a specific user and
|
|
||||||
// correlates to a particular directory on disk.
|
|
||||||
type FileMailbox struct {
|
|
||||||
store *FileDataStore
|
|
||||||
name string
|
|
||||||
dirName string
|
|
||||||
path string
|
|
||||||
indexLoaded bool
|
|
||||||
indexPath string
|
|
||||||
messages []*FileMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name of the mailbox
|
|
||||||
func (mb *FileMailbox) Name() string {
|
|
||||||
return mb.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// String renders the name and directory path of the mailbox
|
|
||||||
func (mb *FileMailbox) String() string {
|
|
||||||
return mb.name + "[" + mb.dirName + "]"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages scans the mailbox directory for .gob files and decodes them into
|
|
||||||
// a slice of Message objects.
|
|
||||||
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := make([]datastore.Message, len(mb.messages))
|
|
||||||
for i, m := range mb.messages {
|
|
||||||
messages[i] = m
|
|
||||||
}
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessage decodes a single message by Id and returns a Message object
|
|
||||||
func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) {
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == "latest" && len(mb.messages) != 0 {
|
|
||||||
return mb.messages[len(mb.messages)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range mb.messages {
|
|
||||||
if m.Fid == id {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, datastore.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge deletes all messages in this mailbox
|
|
||||||
func (mb *FileMailbox) Purge() error {
|
|
||||||
mb.messages = mb.messages[:0]
|
|
||||||
return mb.writeIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
// readIndex loads the mailbox index data from disk
|
|
||||||
func (mb *FileMailbox) readIndex() error {
|
|
||||||
// Clear message slice, open index
|
|
||||||
mb.messages = mb.messages[:0]
|
|
||||||
// Lock for reading
|
|
||||||
indexMx.RLock()
|
|
||||||
defer indexMx.RUnlock()
|
|
||||||
// Check if index exists
|
|
||||||
if _, err := os.Stat(mb.indexPath); err != nil {
|
|
||||||
// Does not exist, but that's not an error in our world
|
|
||||||
log.Tracef("Index %v does not exist (yet)", mb.indexPath)
|
|
||||||
mb.indexLoaded = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
file, err := os.Open(mb.indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Decode gob data
|
|
||||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
|
||||||
for {
|
|
||||||
msg := new(FileMessage)
|
|
||||||
if err = dec.Decode(msg); err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
// It's OK to get an EOF here
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
|
||||||
}
|
|
||||||
msg.mailbox = mb
|
|
||||||
mb.messages = append(mb.messages, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
mb.indexLoaded = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeIndex overwrites the index on disk with the current mailbox data
|
|
||||||
func (mb *FileMailbox) writeIndex() error {
|
|
||||||
// Lock for writing
|
|
||||||
indexMx.Lock()
|
|
||||||
defer indexMx.Unlock()
|
|
||||||
if len(mb.messages) > 0 {
|
|
||||||
// Ensure mailbox directory exists
|
|
||||||
if err := mb.createDir(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Open index for writing
|
|
||||||
file, err := os.Create(mb.indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
writer := bufio.NewWriter(file)
|
|
||||||
// Write each message and then flush
|
|
||||||
enc := gob.NewEncoder(writer)
|
|
||||||
for _, m := range mb.messages {
|
|
||||||
err = enc.Encode(m)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := writer.Flush(); err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No messages, delete index+maildir
|
|
||||||
log.Tracef("Removing mailbox %v", mb.path)
|
|
||||||
return mb.removeDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
|
||||||
func (mb *FileMailbox) createDir() error {
|
|
||||||
dirMx.Lock()
|
|
||||||
defer dirMx.Unlock()
|
|
||||||
if _, err := os.Stat(mb.path); err != nil {
|
|
||||||
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
|
||||||
log.Errorf("Failed to create directory %v, %v", mb.path, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDir removes the mailbox, plus empty higher level directories
|
|
||||||
func (mb *FileMailbox) removeDir() error {
|
|
||||||
dirMx.Lock()
|
|
||||||
defer dirMx.Unlock()
|
|
||||||
// remove mailbox dir, including index file
|
|
||||||
if err := os.RemoveAll(mb.path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// remove parents if empty
|
|
||||||
dir := filepath.Dir(mb.path)
|
|
||||||
if removeDirIfEmpty(dir) {
|
|
||||||
removeDirIfEmpty(filepath.Dir(dir))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
|
|
||||||
// Caller should hold dirMx. Returns true if dir was removed.
|
|
||||||
func removeDirIfEmpty(path string) (removed bool) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
files, err := f.Readdirnames(0)
|
|
||||||
_ = f.Close()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(files) > 0 {
|
|
||||||
// Dir not empty
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log.Tracef("Removing dir %v", path)
|
|
||||||
err = os.Remove(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to remove %q: %v", path, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePrefix converts a Time object into the ISO style format we use
|
|
||||||
// as a prefix for message files. Note: It is used directly by unit
|
|
||||||
// tests.
|
|
||||||
func generatePrefix(date time.Time) string {
|
|
||||||
return date.Format("20060102T150405")
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateId adds a 4-digit unique number onto the end of the string
|
|
||||||
// returned by generatePrefix()
|
|
||||||
func generateID(date time.Time) string {
|
|
||||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
|
||||||
}
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test directory structure created by filestore
|
|
||||||
func TestFSDirStructure(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
root := ds.path
|
|
||||||
|
|
||||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
|
||||||
mbName := "james"
|
|
||||||
|
|
||||||
// Check filestore root exists
|
|
||||||
assert.True(t, isDir(root), "Expected %q to be a directory", root)
|
|
||||||
|
|
||||||
// Check mail dir exists
|
|
||||||
expect := filepath.Join(root, "mail")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
|
|
||||||
// Check first hash section does not exist
|
|
||||||
expect = filepath.Join(root, "mail", "474")
|
|
||||||
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
|
|
||||||
|
|
||||||
// Deliver test message
|
|
||||||
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
|
|
||||||
|
|
||||||
// Check path to message exists
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
expect = filepath.Join(expect, "474ba6")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
|
|
||||||
// Check files
|
|
||||||
mbPath := expect
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
expect = filepath.Join(mbPath, id1+".raw")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Deliver second test message
|
|
||||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
|
||||||
|
|
||||||
// Check files
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
expect = filepath.Join(mbPath, id2+".raw")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Delete message
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err := mb.GetMessage(id1)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
err = msg.Delete()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Message should be removed
|
|
||||||
expect = filepath.Join(mbPath, id1+".raw")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Delete message
|
|
||||||
msg, err = mb.GetMessage(id2)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
err = msg.Delete()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Message should be removed
|
|
||||||
expect = filepath.Join(mbPath, id2+".raw")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
|
|
||||||
// No messages, index & maildir should be removed
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
expect = mbPath
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
|
|
||||||
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 FileDataStore.AllMailboxes()
|
|
||||||
func TestFSAllMailboxes(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
|
|
||||||
// Create day old message
|
|
||||||
date := time.Now().Add(-24 * time.Hour)
|
|
||||||
deliverMessage(ds, name, "Old Message", date)
|
|
||||||
|
|
||||||
// Create current message
|
|
||||||
date = time.Now()
|
|
||||||
deliverMessage(ds, name, "New Message", date)
|
|
||||||
}
|
|
||||||
|
|
||||||
mboxes, err := ds.AllMailboxes()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, len(mboxes), 5)
|
|
||||||
|
|
||||||
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 delivering several messages to the same mailbox, meanwhile querying its
|
|
||||||
// contents with a new mailbox object each time
|
|
||||||
func TestFSDeliverMany(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for i, subj := range subjects {
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, i, len(msgs), "Expected %v message(s), but got %v", i, len(msgs))
|
|
||||||
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Confirm delivery order
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 deleting messages
|
|
||||||
func TestFSDelete(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for _, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Delete a couple messages
|
|
||||||
_ = msgs[1].Delete()
|
|
||||||
_ = msgs[3].Delete()
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subjects = []string{"alpha", "charlie", "echo"}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try appending one more
|
|
||||||
deliverMessage(ds, mbName, "foxtrot", time.Now())
|
|
||||||
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 purging a mailbox
|
|
||||||
func TestFSPurge(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for _, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Purge mailbox
|
|
||||||
err = mb.Purge()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, len(msgs), 0, "Expected mailbox to have zero messages, got %v", len(msgs))
|
|
||||||
|
|
||||||
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 message size calculation
|
|
||||||
func TestFSSize(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "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 {
|
|
||||||
// Add a message
|
|
||||||
id, size := deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
sentIds[i] = id
|
|
||||||
sentSizes[i] = size
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
for i, id := range sentIds {
|
|
||||||
msg, err := mb.GetMessage(id)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
expect := sentSizes[i]
|
|
||||||
size := msg.Size()
|
|
||||||
assert.Equal(t, expect, size, "Expected size of %v, got %v", expect, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 missing files
|
|
||||||
func TestFSMissing(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"a", "b", "c"}
|
|
||||||
sentIds := make([]string, len(subjects))
|
|
||||||
|
|
||||||
for i, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
id, _ := deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
sentIds[i] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete a message file without removing it from index
|
|
||||||
msg, err := mb.GetMessage(sentIds[1])
|
|
||||||
assert.Nil(t, err)
|
|
||||||
fmsg := msg.(*FileMessage)
|
|
||||||
_ = os.Remove(fmsg.rawPath())
|
|
||||||
msg, err = mb.GetMessage(sentIds[1])
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Try to read parts of message
|
|
||||||
_, err = msg.ReadHeader()
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = msg.ReadBody()
|
|
||||||
assert.Error(t, 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 delivering several messages to the same mailbox, see if message cap works
|
|
||||||
func TestFSMessageCap(t *testing.T) {
|
|
||||||
mbCap := 10
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "captain"
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
// Add a message
|
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
t.Logf("Delivered %q", subj)
|
|
||||||
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
if len(msgs) > mbCap {
|
|
||||||
t.Errorf("Mailbox should be capped at %v messages, but has %v", mbCap, len(msgs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
t.Errorf("Expected first subject to be %q, got %q", firstSubj, msgs[0].Subject())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 delivering several messages to the same mailbox, see if no message cap works
|
|
||||||
func TestFSNoMessageCap(t *testing.T) {
|
|
||||||
mbCap := 0
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "captain"
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
// Add a message
|
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
t.Logf("Delivered %q", subj)
|
|
||||||
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
if len(msgs) != i+1 {
|
|
||||||
t.Errorf("Expected %v messages, got %v", i+1, len(msgs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Get the latest message
|
|
||||||
func TestGetLatestMessage(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
|
||||||
mbName := "james"
|
|
||||||
|
|
||||||
// Test empty mailbox
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err := mb.GetMessage("latest")
|
|
||||||
assert.Nil(t, msg)
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
// Deliver test message
|
|
||||||
deliverMessage(ds, mbName, "test", time.Now())
|
|
||||||
|
|
||||||
// Deliver test message 2
|
|
||||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
|
||||||
|
|
||||||
// Test get the latest message
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err = mb.GetMessage("latest")
|
|
||||||
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, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
|
||||||
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err = mb.GetMessage("latest")
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
|
||||||
|
|
||||||
// Test wrong id
|
|
||||||
_, err = mb.GetMessage("wrongid")
|
|
||||||
assert.Error(t, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupDataStore creates a new FileDataStore in a temporary directory
|
|
||||||
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
|
|
||||||
path, err := ioutil.TempDir("", "inbucket")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture log output
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
log.SetOutput(buf)
|
|
||||||
|
|
||||||
cfg.Path = path
|
|
||||||
return NewFileDataStore(cfg).(*FileDataStore), buf
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverMessage creates and delivers a message to the specific mailbox, returning
|
|
||||||
// the size of the generated message.
|
|
||||||
func deliverMessage(ds *FileDataStore, mbName string, subject string,
|
|
||||||
date time.Time) (id string, size int64) {
|
|
||||||
// Build fake SMTP message for delivery
|
|
||||||
testMsg := make([]byte, 0, 300)
|
|
||||||
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte("From: somebodyelse@host\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte(fmt.Sprintf("Subject: %s\r\n", subject))...)
|
|
||||||
testMsg = append(testMsg, []byte("\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte("Test Body\r\n")...)
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
// Create message object
|
|
||||||
id = generateID(date)
|
|
||||||
msg, err := mb.NewMessage()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmsg := msg.(*FileMessage)
|
|
||||||
fmsg.Fdate = date
|
|
||||||
fmsg.Fid = id
|
|
||||||
if err = msg.Append(testMsg); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err = msg.Close(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, int64(len(testMsg))
|
|
||||||
}
|
|
||||||
|
|
||||||
func teardownDataStore(ds *FileDataStore) {
|
|
||||||
if err := os.RemoveAll(ds.path); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPresent(path string) bool {
|
|
||||||
_, err := os.Lstat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFile(path string) bool {
|
|
||||||
if fi, err := os.Lstat(path); err == nil {
|
|
||||||
return !fi.IsDir()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDir(path string) bool {
|
|
||||||
if fi, err := os.Lstat(path); err == nil {
|
|
||||||
return fi.IsDir()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
inbucket.exe etc\win-sample.conf
|
|
||||||
-183
@@ -1,183 +0,0 @@
|
|||||||
// main is the inbucket daemon launcher
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"expvar"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"runtime"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/jhillyerd/inbucket/filestore"
|
|
||||||
"github.com/jhillyerd/inbucket/httpd"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
|
||||||
"github.com/jhillyerd/inbucket/pop3d"
|
|
||||||
"github.com/jhillyerd/inbucket/rest"
|
|
||||||
"github.com/jhillyerd/inbucket/smtpd"
|
|
||||||
"github.com/jhillyerd/inbucket/webui"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// version contains the build version number, populated during linking
|
|
||||||
version = "undefined"
|
|
||||||
|
|
||||||
// date contains the build date, populated during linking
|
|
||||||
date = "undefined"
|
|
||||||
|
|
||||||
// Command line flags
|
|
||||||
help = flag.Bool("help", false, "Displays this help")
|
|
||||||
pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
|
|
||||||
logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
|
|
||||||
|
|
||||||
// shutdownChan - close it to tell Inbucket to shut down cleanly
|
|
||||||
shutdownChan = make(chan bool)
|
|
||||||
|
|
||||||
// Server instances
|
|
||||||
smtpServer *smtpd.Server
|
|
||||||
pop3Server *pop3d.Server
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server uptime for status page
|
|
||||||
startTime := time.Now()
|
|
||||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
|
||||||
return time.Since(startTime) / time.Second
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Goroutine count for status page
|
|
||||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
|
||||||
return runtime.NumGoroutine()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config.Version = version
|
|
||||||
config.BuildDate = date
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
if *help {
|
|
||||||
flag.Usage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root context
|
|
||||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
// Load & Parse config
|
|
||||||
if flag.NArg() != 1 {
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
err := config.LoadConfig(flag.Arg(0))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to parse config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup signal handler
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
|
|
||||||
|
|
||||||
// Initialize logging
|
|
||||||
log.SetLogLevel(config.GetLogLevel())
|
|
||||||
if err := log.Initialize(*logfile); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer log.Close()
|
|
||||||
|
|
||||||
log.Infof("Inbucket %v (%v) starting...", config.Version, config.BuildDate)
|
|
||||||
|
|
||||||
// Write pidfile if requested
|
|
||||||
if *pidfile != "none" {
|
|
||||||
pidf, err := os.Create(*pidfile)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to create %q: %v", *pidfile, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
|
||||||
if err := pidf.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close PID file %q: %v", *pidfile, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create message hub
|
|
||||||
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
|
|
||||||
|
|
||||||
// Grab our datastore
|
|
||||||
ds := filestore.DefaultFileDataStore()
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
|
|
||||||
webui.SetupRoutes(httpd.Router)
|
|
||||||
rest.SetupRoutes(httpd.Router)
|
|
||||||
go httpd.Start(rootCtx)
|
|
||||||
|
|
||||||
// Start POP3 server
|
|
||||||
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
|
|
||||||
go pop3Server.Start(rootCtx)
|
|
||||||
|
|
||||||
// Startup SMTP server
|
|
||||||
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
|
|
||||||
go smtpServer.Start(rootCtx)
|
|
||||||
|
|
||||||
// Loop forever waiting for signals or shutdown channel
|
|
||||||
signalLoop:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case sig := <-sigChan:
|
|
||||||
switch sig {
|
|
||||||
case syscall.SIGHUP:
|
|
||||||
log.Infof("Recieved SIGHUP, cycling logfile")
|
|
||||||
log.Rotate()
|
|
||||||
case syscall.SIGINT:
|
|
||||||
// Shutdown requested
|
|
||||||
log.Infof("Received SIGINT, shutting down")
|
|
||||||
close(shutdownChan)
|
|
||||||
case syscall.SIGTERM:
|
|
||||||
// Shutdown requested
|
|
||||||
log.Infof("Received SIGTERM, shutting down")
|
|
||||||
close(shutdownChan)
|
|
||||||
}
|
|
||||||
case <-shutdownChan:
|
|
||||||
rootCancel()
|
|
||||||
break signalLoop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for active connections to finish
|
|
||||||
go timedExit()
|
|
||||||
smtpServer.Drain()
|
|
||||||
pop3Server.Drain()
|
|
||||||
|
|
||||||
removePIDFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
// removePIDFile removes the PID file if created
|
|
||||||
func removePIDFile() {
|
|
||||||
if *pidfile != "none" {
|
|
||||||
if err := os.Remove(*pidfile); err != nil {
|
|
||||||
log.Errorf("Failed to remove %q: %v", *pidfile, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// timedExit is called as a goroutine during shutdown, it will force an exit
|
|
||||||
// after 15 seconds
|
|
||||||
func timedExit() {
|
|
||||||
time.Sleep(15 * time.Second)
|
|
||||||
log.Errorf("Clean shutdown took too long, forcing exit")
|
|
||||||
removePIDFile()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
golog "log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Level is used to indicate the severity of a log entry
|
|
||||||
type Level int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ERROR indicates a significant problem was encountered
|
|
||||||
ERROR Level = iota
|
|
||||||
// WARN indicates something that may be a problem
|
|
||||||
WARN
|
|
||||||
// INFO indicates a purely informational log entry
|
|
||||||
INFO
|
|
||||||
// TRACE entries are meant for development purposes only
|
|
||||||
TRACE
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
|
|
||||||
MaxLevel = TRACE
|
|
||||||
|
|
||||||
// logfname is the name of the logfile
|
|
||||||
logfname string
|
|
||||||
|
|
||||||
// logf is the file we send log output to, will be nil for stderr or stdout
|
|
||||||
logf *os.File
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize logging. If logfile is equal to "stderr" or "stdout", then
|
|
||||||
// we will log to that output stream. Otherwise the specificed file will
|
|
||||||
// opened for writing, and all log data will be placed in it.
|
|
||||||
func Initialize(logfile string) error {
|
|
||||||
if logfile != "stderr" {
|
|
||||||
// stderr is the go logging default
|
|
||||||
if logfile == "stdout" {
|
|
||||||
// set to stdout
|
|
||||||
golog.SetOutput(os.Stdout)
|
|
||||||
} else {
|
|
||||||
logfname = logfile
|
|
||||||
if err := openLogFile(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Platform specific
|
|
||||||
closeStdin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLogLevel sets MaxLevel based on the provided string
|
|
||||||
func SetLogLevel(level string) (ok bool) {
|
|
||||||
switch strings.ToUpper(level) {
|
|
||||||
case "ERROR":
|
|
||||||
MaxLevel = ERROR
|
|
||||||
case "WARN":
|
|
||||||
MaxLevel = WARN
|
|
||||||
case "INFO":
|
|
||||||
MaxLevel = INFO
|
|
||||||
case "TRACE":
|
|
||||||
MaxLevel = TRACE
|
|
||||||
default:
|
|
||||||
Errorf("Unknown log level requested: " + level)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
|
|
||||||
func Errorf(msg string, args ...interface{}) {
|
|
||||||
msg = "[ERROR] " + msg
|
|
||||||
golog.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnf logs a message to the 'standard' Logger if MaxLevel is >= WARN, accepts format strings
|
|
||||||
func Warnf(msg string, args ...interface{}) {
|
|
||||||
if MaxLevel >= WARN {
|
|
||||||
msg = "[WARN ] " + msg
|
|
||||||
golog.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infof logs a message to the 'standard' Logger if MaxLevel is >= INFO, accepts format strings
|
|
||||||
func Infof(msg string, args ...interface{}) {
|
|
||||||
if MaxLevel >= INFO {
|
|
||||||
msg = "[INFO ] " + msg
|
|
||||||
golog.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracef logs a message to the 'standard' Logger if MaxLevel is >= TRACE, accepts format strings
|
|
||||||
func Tracef(msg string, args ...interface{}) {
|
|
||||||
if MaxLevel >= TRACE {
|
|
||||||
msg = "[TRACE] " + msg
|
|
||||||
golog.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate closes the current log file, then reopens it. This gives an external
|
|
||||||
// log rotation system the opportunity to move the existing log file out of the
|
|
||||||
// way and have Inbucket create a new one.
|
|
||||||
func Rotate() {
|
|
||||||
// Rotate logs if configured
|
|
||||||
if logf != nil {
|
|
||||||
closeLogFile()
|
|
||||||
// There is nothing we can do if the log open fails
|
|
||||||
_ = openLogFile()
|
|
||||||
} else {
|
|
||||||
Infof("Ignoring SIGHUP, logfile not configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the log file if we have one open
|
|
||||||
func Close() {
|
|
||||||
if logf != nil {
|
|
||||||
closeLogFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openLogFile creates or appends to the logfile passed on commandline
|
|
||||||
func openLogFile() error {
|
|
||||||
// use specified log file
|
|
||||||
var err error
|
|
||||||
logf, err = os.OpenFile(logfname, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to create %v: %v\n", logfname, err)
|
|
||||||
}
|
|
||||||
golog.SetOutput(logf)
|
|
||||||
Tracef("Opened new logfile")
|
|
||||||
// Platform specific
|
|
||||||
reassignStdout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeLogFile closes the current logfile
|
|
||||||
func closeLogFile() {
|
|
||||||
Tracef("Closing logfile")
|
|
||||||
// We are never in a situation where we can do anything about failing to close
|
|
||||||
_ = logf.Close()
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// +build !windows
|
|
||||||
|
|
||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// closeStdin will close stdin on Unix platforms - this is standard practice
|
|
||||||
// for daemons
|
|
||||||
func closeStdin() {
|
|
||||||
if err := os.Stdin.Close(); err != nil {
|
|
||||||
// Not a fatal error
|
|
||||||
Errorf("Failed to close os.Stdin during log setup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reassignStdout points stdout/stderr to our logfile on systems that support
|
|
||||||
// the Dup2 syscall per https://github.com/golang/go/issues/325
|
|
||||||
func reassignStdout() {
|
|
||||||
Tracef("Unix reassignStdout()")
|
|
||||||
if err := unix.Dup2(int(logf.Fd()), 1); err != nil {
|
|
||||||
// Not considered fatal
|
|
||||||
Errorf("Failed to re-assign stdout to logfile: %v", err)
|
|
||||||
}
|
|
||||||
if err := unix.Dup2(int(logf.Fd()), 2); err != nil {
|
|
||||||
// Not considered fatal
|
|
||||||
Errorf("Failed to re-assign stderr to logfile: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// +build windows
|
|
||||||
|
|
||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var stdOutsClosed = false
|
|
||||||
|
|
||||||
// closeStdin does nothing on Windows, it would always fail
|
|
||||||
func closeStdin() {
|
|
||||||
// Nop
|
|
||||||
}
|
|
||||||
|
|
||||||
// reassignStdout points stdout/stderr to our logfile on systems that do not
|
|
||||||
// support the Dup2 syscall
|
|
||||||
func reassignStdout() {
|
|
||||||
Tracef("Windows reassignStdout()")
|
|
||||||
if !stdOutsClosed {
|
|
||||||
// Close std* streams to prevent accidental output, they will be redirected to
|
|
||||||
// our logfile below
|
|
||||||
|
|
||||||
// Warning: this will hide panic() output, sorry Windows users
|
|
||||||
if err := os.Stderr.Close(); err != nil {
|
|
||||||
// Not considered fatal
|
|
||||||
Errorf("Failed to close os.Stderr during log setup")
|
|
||||||
}
|
|
||||||
if err := os.Stdin.Close(); err != nil {
|
|
||||||
// Not considered fatal
|
|
||||||
Errorf("Failed to close os.Stdin during log setup")
|
|
||||||
}
|
|
||||||
os.Stdout = logf
|
|
||||||
os.Stderr = logf
|
|
||||||
stdOutsClosed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
prefix = "inbucket"
|
||||||
|
tableFormat = `Inbucket is configured via the environment. The following environment variables
|
||||||
|
can be used:
|
||||||
|
|
||||||
|
KEY DEFAULT DESCRIPTION
|
||||||
|
{{range .}}{{usage_key .}} {{usage_default .}} {{usage_description .}}
|
||||||
|
{{end}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Version of this build, set by main
|
||||||
|
Version = ""
|
||||||
|
|
||||||
|
// BuildDate for this build, set by main
|
||||||
|
BuildDate = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// mbNaming represents a mailbox naming strategy.
|
||||||
|
type mbNaming int
|
||||||
|
|
||||||
|
// Mailbox naming strategies.
|
||||||
|
const (
|
||||||
|
UnknownNaming mbNaming = iota
|
||||||
|
LocalNaming
|
||||||
|
FullNaming
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decode a naming strategy from string.
|
||||||
|
func (n *mbNaming) Decode(v string) error {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "local":
|
||||||
|
*n = LocalNaming
|
||||||
|
case "full":
|
||||||
|
*n = FullNaming
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root contains global configuration, and structs with for specific sub-systems.
|
||||||
|
type Root struct {
|
||||||
|
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||||
|
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
|
||||||
|
SMTP SMTP
|
||||||
|
POP3 POP3
|
||||||
|
Web Web
|
||||||
|
Storage Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP contains the SMTP server configuration.
|
||||||
|
type SMTP struct {
|
||||||
|
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||||
|
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
||||||
|
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
||||||
|
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
||||||
|
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
||||||
|
AcceptDomains []string `desc:"Domains to accept mail for"`
|
||||||
|
RejectDomains []string `desc:"Domains to reject mail for"`
|
||||||
|
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
||||||
|
StoreDomains []string `desc:"Domains to store mail for"`
|
||||||
|
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||||
|
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web contains the HTTP server configuration.
|
||||||
|
type Web struct {
|
||||||
|
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||||
|
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
|
||||||
|
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||||
|
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
|
||||||
|
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
|
||||||
|
CookieAuthKey string `desc:"Session cipher key (text)"`
|
||||||
|
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||||
|
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage contains the mail store configuration.
|
||||||
|
type Storage struct {
|
||||||
|
Type string `required:"true" default:"memory" desc:"Storage impl: file or memory"`
|
||||||
|
Params map[string]string `desc:"Storage impl parameters, see docs."`
|
||||||
|
RetentionPeriod time.Duration `required:"true" default:"24h" desc:"Duration to retain messages"`
|
||||||
|
RetentionSleep time.Duration `required:"true" default:"50ms" desc:"Duration to sleep between mailboxes"`
|
||||||
|
MailboxMsgCap int `required:"true" default:"500" desc:"Maximum messages per mailbox"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process loads and parses configuration from the environment.
|
||||||
|
func Process() (*Root, error) {
|
||||||
|
c := &Root{}
|
||||||
|
err := envconfig.Process(prefix, c)
|
||||||
|
c.LogLevel = strings.ToLower(c.LogLevel)
|
||||||
|
stringutil.SliceToLower(c.SMTP.AcceptDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.RejectDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.StoreDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.DiscardDomains)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints out the envconfig usage to Stderr.
|
||||||
|
func Usage() {
|
||||||
|
tabs := tabwriter.NewWriter(os.Stderr, 1, 0, 4, ' ', 0)
|
||||||
|
if err := envconfig.Usagef(prefix, &Root{}, tabs, tableFormat); err != nil {
|
||||||
|
log.Fatalf("Unable to parse env config: %v", err)
|
||||||
|
}
|
||||||
|
tabs.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager is the interface controllers use to interact with messages.
|
||||||
|
type Manager interface {
|
||||||
|
Deliver(
|
||||||
|
to *policy.Recipient,
|
||||||
|
from string,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
prefix string,
|
||||||
|
content []byte,
|
||||||
|
) (id string, err error)
|
||||||
|
GetMetadata(mailbox string) ([]*Metadata, error)
|
||||||
|
GetMessage(mailbox, id string) (*Message, error)
|
||||||
|
MarkSeen(mailbox, id string) error
|
||||||
|
PurgeMessages(mailbox string) error
|
||||||
|
RemoveMessage(mailbox, id string) error
|
||||||
|
SourceReader(mailbox, id string) (io.ReadCloser, error)
|
||||||
|
MailboxForAddress(address string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreManager is a message Manager backed by the storage.Store.
|
||||||
|
type StoreManager struct {
|
||||||
|
AddrPolicy *policy.Addressing
|
||||||
|
Store storage.Store
|
||||||
|
Hub *msghub.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver submits a new message to the store.
|
||||||
|
func (s *StoreManager) Deliver(
|
||||||
|
to *policy.Recipient,
|
||||||
|
from string,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
prefix string,
|
||||||
|
source []byte,
|
||||||
|
) (string, error) {
|
||||||
|
// TODO enmime is too heavy for this step, only need header.
|
||||||
|
// Go's header parsing isn't good enough, so this is blocked on enmime issue #64.
|
||||||
|
env, err := enmime.ReadEnvelope(bytes.NewReader(source))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fromaddr, err := env.AddressList("From")
|
||||||
|
if err != nil || len(fromaddr) == 0 {
|
||||||
|
fromaddr = []*mail.Address{{Address: from}}
|
||||||
|
}
|
||||||
|
toaddr, err := env.AddressList("To")
|
||||||
|
if err != nil {
|
||||||
|
toaddr = make([]*mail.Address, len(recipients))
|
||||||
|
for i, torecip := range recipients {
|
||||||
|
toaddr[i] = &torecip.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
||||||
|
delivery := &Delivery{
|
||||||
|
Meta: Metadata{
|
||||||
|
Mailbox: to.Mailbox,
|
||||||
|
From: fromaddr[0],
|
||||||
|
To: toaddr,
|
||||||
|
Date: time.Now(),
|
||||||
|
Subject: env.GetHeader("Subject"),
|
||||||
|
},
|
||||||
|
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
|
||||||
|
}
|
||||||
|
id, err := s.Store.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.Hub != nil {
|
||||||
|
// Broadcast message information.
|
||||||
|
broadcast := msghub.Message{
|
||||||
|
Mailbox: to.Mailbox,
|
||||||
|
ID: id,
|
||||||
|
From: delivery.From().String(),
|
||||||
|
To: stringutil.StringAddressList(delivery.To()),
|
||||||
|
Subject: delivery.Subject(),
|
||||||
|
Date: delivery.Date(),
|
||||||
|
Size: delivery.Size(),
|
||||||
|
}
|
||||||
|
s.Hub.Dispatch(broadcast)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata returns a slice of metadata for the specified mailbox.
|
||||||
|
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) {
|
||||||
|
messages, err := s.Store.GetMessages(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
metas := make([]*Metadata, len(messages))
|
||||||
|
for i, sm := range messages {
|
||||||
|
metas[i] = makeMetadata(sm)
|
||||||
|
}
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns the specified message.
|
||||||
|
func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
|
||||||
|
sm, err := s.Store.GetMessage(mailbox, id)
|
||||||
|
if err != nil || sm == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := sm.Source()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env, err := enmime.ReadEnvelope(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
header := makeMetadata(sm)
|
||||||
|
return &Message{Metadata: *header, env: env}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks the message as having been read.
|
||||||
|
func (s *StoreManager) MarkSeen(mailbox, id string) error {
|
||||||
|
log.Debug().Str("module", "manager").Str("mailbox", mailbox).Str("id", id).
|
||||||
|
Msg("Marking as seen")
|
||||||
|
return s.Store.MarkSeen(mailbox, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages removes all messages from the specified mailbox.
|
||||||
|
func (s *StoreManager) PurgeMessages(mailbox string) error {
|
||||||
|
return s.Store.PurgeMessages(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes the specified message.
|
||||||
|
func (s *StoreManager) RemoveMessage(mailbox, id string) error {
|
||||||
|
return s.Store.RemoveMessage(mailbox, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceReader allows the stored message source to be read.
|
||||||
|
func (s *StoreManager) SourceReader(mailbox, id string) (io.ReadCloser, error) {
|
||||||
|
sm, err := s.Store.GetMessage(mailbox, id)
|
||||||
|
if err != nil || sm == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sm.Source()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxForAddress parses an email address to return the canonical mailbox name.
|
||||||
|
func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
|
||||||
|
return s.AddrPolicy.ExtractMailbox(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeMetadata populates Metadata from a storage.Message.
|
||||||
|
func makeMetadata(m storage.Message) *Metadata {
|
||||||
|
return &Metadata{
|
||||||
|
Mailbox: m.Mailbox(),
|
||||||
|
ID: m.ID(),
|
||||||
|
From: m.From(),
|
||||||
|
To: m.To(),
|
||||||
|
Date: m.Date(),
|
||||||
|
Subject: m.Subject(),
|
||||||
|
Size: m.Size(),
|
||||||
|
Seen: m.Seen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Package message contains message handling logic.
|
||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata holds information about a message, but not the content.
|
||||||
|
type Metadata struct {
|
||||||
|
Mailbox string
|
||||||
|
ID string
|
||||||
|
From *mail.Address
|
||||||
|
To []*mail.Address
|
||||||
|
Date time.Time
|
||||||
|
Subject string
|
||||||
|
Size int64
|
||||||
|
Seen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message holds both the metadata and content of a message.
|
||||||
|
type Message struct {
|
||||||
|
Metadata
|
||||||
|
env *enmime.Envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new Message
|
||||||
|
func New(m Metadata, e *enmime.Envelope) *Message {
|
||||||
|
return &Message{
|
||||||
|
Metadata: m,
|
||||||
|
env: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments returns the MIME attachments for the message.
|
||||||
|
func (m *Message) Attachments() []*enmime.Part {
|
||||||
|
return m.env.Attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header returns the header map for this message.
|
||||||
|
func (m *Message) Header() textproto.MIMEHeader {
|
||||||
|
return m.env.Root.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML returns the HTML body of the message.
|
||||||
|
func (m *Message) HTML() string {
|
||||||
|
return m.env.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIMEErrors returns MIME parsing errors and warnings.
|
||||||
|
func (m *Message) MIMEErrors() []*enmime.Error {
|
||||||
|
return m.env.Errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text returns the plain text body of the message.
|
||||||
|
func (m *Message) Text() string {
|
||||||
|
return m.env.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery is used to add a message to storage.
|
||||||
|
type Delivery struct {
|
||||||
|
Meta Metadata
|
||||||
|
Reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Message = &Delivery{}
|
||||||
|
|
||||||
|
// Mailbox getter.
|
||||||
|
func (d *Delivery) Mailbox() string {
|
||||||
|
return d.Meta.Mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID getter.
|
||||||
|
func (d *Delivery) ID() string {
|
||||||
|
return d.Meta.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// From getter.
|
||||||
|
func (d *Delivery) From() *mail.Address {
|
||||||
|
return d.Meta.From
|
||||||
|
}
|
||||||
|
|
||||||
|
// To getter.
|
||||||
|
func (d *Delivery) To() []*mail.Address {
|
||||||
|
return d.Meta.To
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date getter.
|
||||||
|
func (d *Delivery) Date() time.Time {
|
||||||
|
return d.Meta.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject getter.
|
||||||
|
func (d *Delivery) Subject() string {
|
||||||
|
return d.Meta.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size getter.
|
||||||
|
func (d *Delivery) Size() int64 {
|
||||||
|
return d.Meta.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source contains the raw content of the message.
|
||||||
|
func (d *Delivery) Source() (io.ReadCloser, error) {
|
||||||
|
return ioutil.NopCloser(d.Reader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen getter.
|
||||||
|
func (d *Delivery) Seen() bool {
|
||||||
|
return d.Meta.Seen
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package log
|
package metric
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TickerFunc is the type of metrics function accepted by AddTickerFunc
|
// TickerFunc is the function signature accepted by AddTickerFunc, will be called once per minute.
|
||||||
type TickerFunc func()
|
type TickerFunc func()
|
||||||
|
|
||||||
var tickerFuncChan = make(chan TickerFunc)
|
var tickerFuncChan = make(chan TickerFunc)
|
||||||
@@ -22,10 +22,10 @@ func AddTickerFunc(f TickerFunc) {
|
|||||||
tickerFuncChan <- f
|
tickerFuncChan <- f
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushMetric adds the metric to the end of the list and returns a comma separated string of the
|
// Push adds the metric to the end of the list and returns a comma separated string of the
|
||||||
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
|
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
|
||||||
// tracks deltas between these values - there is nothing to compare the first value against.
|
// tracks deltas between these values - there is nothing to compare the first value against.
|
||||||
func PushMetric(history *list.List, ev expvar.Var) string {
|
func Push(history *list.List, ev expvar.Var) string {
|
||||||
history.PushBack(ev.String())
|
history.PushBack(ev.String())
|
||||||
if history.Len() > 61 {
|
if history.Len() > 61 {
|
||||||
history.Remove(history.Front())
|
history.Remove(history.Front())
|
||||||
@@ -33,18 +33,7 @@ func PushMetric(history *list.List, ev expvar.Var) string {
|
|||||||
return joinStringList(history)
|
return joinStringList(history)
|
||||||
}
|
}
|
||||||
|
|
||||||
// joinStringList joins a List containing strings by commas
|
// metricsTicker calls the current list of TickerFuncs once per minute.
|
||||||
func joinStringList(listOfStrings *list.List) string {
|
|
||||||
if listOfStrings.Len() == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s := make([]string, 0, listOfStrings.Len())
|
|
||||||
for e := listOfStrings.Front(); e != nil; e = e.Next() {
|
|
||||||
s = append(s, e.Value.(string))
|
|
||||||
}
|
|
||||||
return strings.Join(s, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func metricsTicker() {
|
func metricsTicker() {
|
||||||
funcs := make([]TickerFunc, 0)
|
funcs := make([]TickerFunc, 0)
|
||||||
ticker := time.NewTicker(time.Minute)
|
ticker := time.NewTicker(time.Minute)
|
||||||
@@ -60,3 +49,15 @@ func metricsTicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// joinStringList joins a List containing strings by commas.
|
||||||
|
func joinStringList(listOfStrings *list.List) string {
|
||||||
|
if listOfStrings.Len() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := make([]string, 0, listOfStrings.Len())
|
||||||
|
for e := listOfStrings.Front(); e != nil; e = e.Next() {
|
||||||
|
s = append(s, e.Value.(string))
|
||||||
|
}
|
||||||
|
return strings.Join(s, ",")
|
||||||
|
}
|
||||||
@@ -1,58 +1,112 @@
|
|||||||
package stringutil
|
package policy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha1"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
|
// Addressing handles email address policy.
|
||||||
// and returns just the mailbox name (ex: "user"). Returns an error if
|
type Addressing struct {
|
||||||
// localPart contains invalid characters; it won't accept any that must be
|
Config *config.Root
|
||||||
// quoted according to RFC3696.
|
|
||||||
func ParseMailboxName(localPart string) (result string, err error) {
|
|
||||||
if localPart == "" {
|
|
||||||
return "", fmt.Errorf("Mailbox name cannot be empty")
|
|
||||||
}
|
|
||||||
result = strings.ToLower(localPart)
|
|
||||||
|
|
||||||
invalid := make([]byte, 0, 10)
|
|
||||||
|
|
||||||
for i := 0; i < len(result); i++ {
|
|
||||||
c := result[i]
|
|
||||||
switch {
|
|
||||||
case 'a' <= c && c <= 'z':
|
|
||||||
case '0' <= c && c <= '9':
|
|
||||||
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
|
|
||||||
default:
|
|
||||||
invalid = append(invalid, c)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(invalid) > 0 {
|
// ExtractMailbox extracts the mailbox name from a partial email address.
|
||||||
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
|
func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
||||||
|
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 domain == "" {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if !ValidateDomainPart(domain) {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
|
}
|
||||||
|
return local + "@" + domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(result, "+"); idx > -1 {
|
// NewRecipient parses an address into a Recipient.
|
||||||
result = result[0:idx]
|
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
||||||
|
local, domain, err := ParseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return result, nil
|
mailbox, err := a.ExtractMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ar, err := mail.ParseAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Recipient{
|
||||||
|
Address: *ar,
|
||||||
|
addrPolicy: a,
|
||||||
|
LocalPart: local,
|
||||||
|
Domain: domain,
|
||||||
|
Mailbox: mailbox,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as
|
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
|
||||||
// the directory to house the mailbox
|
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
|
||||||
func HashMailboxName(mailbox string) string {
|
domain = strings.ToLower(domain)
|
||||||
h := sha1.New()
|
if a.Config.SMTP.DefaultAccept &&
|
||||||
if _, err := io.WriteString(h, mailbox); err != nil {
|
!stringutil.SliceContains(a.Config.SMTP.RejectDomains, domain) {
|
||||||
// This shouldn't ever happen
|
return true
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
if !a.Config.SMTP.DefaultAccept &&
|
||||||
|
stringutil.SliceContains(a.Config.SMTP.AcceptDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
|
// ShouldStoreDomain indicates if Inbucket stores mail destined for the specified domain.
|
||||||
|
func (a *Addressing) ShouldStoreDomain(domain string) bool {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
if a.Config.SMTP.DefaultStore &&
|
||||||
|
!stringutil.SliceContains(a.Config.SMTP.DiscardDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !a.Config.SMTP.DefaultStore &&
|
||||||
|
stringutil.SliceContains(a.Config.SMTP.StoreDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func ParseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
|
local, domain, err = parseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !ValidateDomainPart(domain) {
|
||||||
|
return "", "", fmt.Errorf("Domain part validation failed")
|
||||||
|
}
|
||||||
|
return local, domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
|
||||||
|
// ParseEmailAddress().
|
||||||
func ValidateDomainPart(domain string) bool {
|
func ValidateDomainPart(domain string) bool {
|
||||||
if len(domain) == 0 {
|
if len(domain) == 0 {
|
||||||
return false
|
return false
|
||||||
@@ -66,22 +120,21 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
prev := '.'
|
prev := '.'
|
||||||
labelLen := 0
|
labelLen := 0
|
||||||
hasAlphaNum := false
|
hasAlphaNum := false
|
||||||
|
|
||||||
for _, c := range domain {
|
for _, c := range domain {
|
||||||
switch {
|
switch {
|
||||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||||
('0' <= c && c <= '9') || c == '_':
|
('0' <= c && c <= '9') || c == '_':
|
||||||
// Must contain some of these to be a valid label
|
// Must contain some of these to be a valid label.
|
||||||
hasAlphaNum = true
|
hasAlphaNum = true
|
||||||
labelLen++
|
labelLen++
|
||||||
case c == '-':
|
case c == '-':
|
||||||
if prev == '.' {
|
if prev == '.' {
|
||||||
// Cannot lead with hyphen
|
// Cannot lead with hyphen.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case c == '.':
|
case c == '.':
|
||||||
if prev == '.' || prev == '-' {
|
if prev == '.' || prev == '-' {
|
||||||
// Cannot end with hyphen or double-dot
|
// Cannot end with hyphen or double-dot.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if labelLen > 63 {
|
if labelLen > 63 {
|
||||||
@@ -93,19 +146,18 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
labelLen = 0
|
labelLen = 0
|
||||||
hasAlphaNum = false
|
hasAlphaNum = false
|
||||||
default:
|
default:
|
||||||
// Unknown character
|
// Unknown character.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
prev = c
|
prev = c
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
|
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
|
||||||
// An error is returned if the local or domain parts fail validation following the guidelines
|
// error is returned if the local part fails validation following the guidelines in RFC3696. The
|
||||||
// in RFC3696.
|
// domain part is optional and not validated.
|
||||||
func ParseEmailAddress(address string) (local string, domain string, err error) {
|
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
if address == "" {
|
if address == "" {
|
||||||
return "", "", fmt.Errorf("Empty address")
|
return "", "", fmt.Errorf("Empty address")
|
||||||
}
|
}
|
||||||
@@ -118,8 +170,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
|
|||||||
if address[0] == '.' {
|
if address[0] == '.' {
|
||||||
return "", "", fmt.Errorf("Address cannot start with a period")
|
return "", "", fmt.Errorf("Address cannot start with a period")
|
||||||
}
|
}
|
||||||
|
// Loop over address parsing out local part.
|
||||||
// Loop over address parsing out local part
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
prev := byte('.')
|
prev := byte('.')
|
||||||
inCharQuote := false
|
inCharQuote := false
|
||||||
@@ -129,30 +180,30 @@ LOOP:
|
|||||||
c := address[i]
|
c := address[i]
|
||||||
switch {
|
switch {
|
||||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
|
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
|
||||||
// Letters are OK
|
// Letters are OK.
|
||||||
err = buf.WriteByte(c)
|
err = buf.WriteByte(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inCharQuote = false
|
inCharQuote = false
|
||||||
case '0' <= c && c <= '9':
|
case '0' <= c && c <= '9':
|
||||||
// Numbers are OK
|
// Numbers are OK.
|
||||||
err = buf.WriteByte(c)
|
err = buf.WriteByte(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inCharQuote = false
|
inCharQuote = false
|
||||||
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
|
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
|
||||||
// These specials can be used unquoted
|
// These specials can be used unquoted.
|
||||||
err = buf.WriteByte(c)
|
err = buf.WriteByte(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inCharQuote = false
|
inCharQuote = false
|
||||||
case c == '.':
|
case c == '.':
|
||||||
// A single period is OK
|
// A single period is OK.
|
||||||
if prev == '.' {
|
if prev == '.' {
|
||||||
// Sequence of periods is not permitted
|
// Sequence of periods is not permitted.
|
||||||
return "", "", fmt.Errorf("Sequence of periods is not permitted")
|
return "", "", fmt.Errorf("Sequence of periods is not permitted")
|
||||||
}
|
}
|
||||||
err = buf.WriteByte(c)
|
err = buf.WriteByte(c)
|
||||||
@@ -186,7 +237,7 @@ LOOP:
|
|||||||
}
|
}
|
||||||
inCharQuote = false
|
inCharQuote = false
|
||||||
} else {
|
} else {
|
||||||
// End of local-part
|
// End of local-part.
|
||||||
if i > 128 {
|
if i > 128 {
|
||||||
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
|
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
|
||||||
}
|
}
|
||||||
@@ -217,10 +268,34 @@ LOOP:
|
|||||||
if inStringQuote {
|
if inStringQuote {
|
||||||
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
|
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ValidateDomainPart(domain) {
|
|
||||||
return "", "", fmt.Errorf("Domain part validation failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.String(), domain, nil
|
return buf.String(), domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
|
||||||
|
// and returns just the mailbox name (ex: "user"). Returns an error if
|
||||||
|
// localPart contains invalid characters; it won't accept any that must be
|
||||||
|
// quoted according to RFC3696.
|
||||||
|
func parseMailboxName(localPart string) (result string, err error) {
|
||||||
|
if localPart == "" {
|
||||||
|
return "", fmt.Errorf("Mailbox name cannot be empty")
|
||||||
|
}
|
||||||
|
result = strings.ToLower(localPart)
|
||||||
|
invalid := make([]byte, 0, 10)
|
||||||
|
for i := 0; i < len(result); i++ {
|
||||||
|
c := result[i]
|
||||||
|
switch {
|
||||||
|
case 'a' <= c && c <= 'z':
|
||||||
|
case '0' <= c && c <= '9':
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if idx := strings.Index(result, "+"); idx > -1 {
|
||||||
|
result = result[0:idx]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package policy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAcceptDomain(t *testing.T) {
|
||||||
|
// Test with default accept.
|
||||||
|
ap := &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultAccept: true,
|
||||||
|
RejectDomains: []string{"a.deny.com", "deny.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "bar.com", want: true},
|
||||||
|
{domain: "DENY.com", want: false},
|
||||||
|
{domain: "a.deny.com", want: false},
|
||||||
|
{domain: "b.deny.com", want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldAcceptDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Test with default reject.
|
||||||
|
ap = &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultAccept: false,
|
||||||
|
AcceptDomains: []string{"a.allow.com", "allow.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases = []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "bar.com", want: false},
|
||||||
|
{domain: "ALLOW.com", want: true},
|
||||||
|
{domain: "a.allow.com", want: true},
|
||||||
|
{domain: "b.allow.com", want: false},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldAcceptDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldStoreDomain(t *testing.T) {
|
||||||
|
// Test with storage enabled.
|
||||||
|
ap := &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultStore: false,
|
||||||
|
StoreDomains: []string{"store.com", "a.store.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "foo.com", want: false},
|
||||||
|
{domain: "STORE.com", want: true},
|
||||||
|
{domain: "a.store.com", want: true},
|
||||||
|
{domain: "b.store.com", want: false},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldStoreDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Test with storage disabled.
|
||||||
|
ap = &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultStore: true,
|
||||||
|
DiscardDomains: []string{"discard.com", "a.discard.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases = []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "foo.com", want: true},
|
||||||
|
{domain: "DISCARD.com", want: false},
|
||||||
|
{domain: "a.discard.com", want: false},
|
||||||
|
{domain: "b.discard.com", want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldStoreDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMailboxValid(t *testing.T) {
|
||||||
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
|
||||||
|
testTable := []struct {
|
||||||
|
input string // Input to test
|
||||||
|
local string // Expected output when mailbox naming = local
|
||||||
|
full string // Expected output when mailbox naming = full
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "mailbox",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user123",
|
||||||
|
local: "user123",
|
||||||
|
full: "user123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "MailBOX",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "First.Last",
|
||||||
|
local: "first.last",
|
||||||
|
full: "first.last",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user+label",
|
||||||
|
local: "user",
|
||||||
|
full: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars!#$%",
|
||||||
|
local: "chars!#$%",
|
||||||
|
full: "chars!#$%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars&'*-",
|
||||||
|
local: "chars&'*-",
|
||||||
|
full: "chars&'*-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars=/?^",
|
||||||
|
local: "chars=/?^",
|
||||||
|
full: "chars=/?^",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars_`.{",
|
||||||
|
local: "chars_`.{",
|
||||||
|
full: "chars_`.{",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "mailbox@domain.com",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user123@domain.com",
|
||||||
|
local: "user123",
|
||||||
|
full: "user123@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "MailBOX@domain.com",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "First.Last@domain.com",
|
||||||
|
local: "first.last",
|
||||||
|
full: "first.last@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user+label@domain.com",
|
||||||
|
local: "user",
|
||||||
|
full: "user@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars!#$%@domain.com",
|
||||||
|
local: "chars!#$%",
|
||||||
|
full: "chars!#$%@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars&'*-@domain.com",
|
||||||
|
local: "chars&'*-",
|
||||||
|
full: "chars&'*-@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars=/?^@domain.com",
|
||||||
|
local: "chars=/?^",
|
||||||
|
full: "chars=/?^@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars_`.{@domain.com",
|
||||||
|
local: "chars_`.{",
|
||||||
|
full: "chars_`.{@domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~@domain.com",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~@domain.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMailboxInvalid(t *testing.T) {
|
||||||
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
// Test local mailbox naming policy.
|
||||||
|
localInvalidTable := []struct {
|
||||||
|
input, msg string
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"first last", "Space not permitted"},
|
||||||
|
{"first\"last", "Double quote not permitted"},
|
||||||
|
{"first\nlast", "Control chars not permitted"},
|
||||||
|
}
|
||||||
|
for _, tt := range localInvalidTable {
|
||||||
|
if _, err := localPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
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
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"user@host@domain.com", "@ symbol not permitted"},
|
||||||
|
{"first last@domain.com", "Space not permitted"},
|
||||||
|
{"first\"last@domain.com", "Double quote not permitted"},
|
||||||
|
{"first\nlast@domain.com", "Control chars not permitted"},
|
||||||
|
}
|
||||||
|
for _, tt := range fullInvalidTable {
|
||||||
|
if _, err := fullPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomain(t *testing.T) {
|
||||||
|
testTable := []struct {
|
||||||
|
input string
|
||||||
|
expect bool
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{"", false, "Empty domain is not valid"},
|
||||||
|
{"hostname", true, "Just a hostname is valid"},
|
||||||
|
{"github.com", true, "Two labels should be just fine"},
|
||||||
|
{"my-domain.com", true, "Hyphen is allowed mid-label"},
|
||||||
|
{"_domainkey.foo.com", true, "Underscores are allowed"},
|
||||||
|
{"bar.com.", true, "Must be able to end with a dot"},
|
||||||
|
{"ABC.6DBS.com", true, "Mixed case is OK"},
|
||||||
|
{"mail.123.com", true, "Number only label valid"},
|
||||||
|
{"123.com", true, "Number only label valid"},
|
||||||
|
{"google..com", false, "Double dot not valid"},
|
||||||
|
{".foo.com", false, "Cannot start with a dot"},
|
||||||
|
{"google\r.com", false, "Special chars not allowed"},
|
||||||
|
{"foo.-bar.com", false, "Label cannot start with hyphen"},
|
||||||
|
{"foo-.bar.com", false, "Label cannot end with hyphen"},
|
||||||
|
{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"},
|
||||||
|
}
|
||||||
|
for _, tt := range testTable {
|
||||||
|
if policy.ValidateDomainPart(tt.input) != tt.expect {
|
||||||
|
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLocal(t *testing.T) {
|
||||||
|
testTable := []struct {
|
||||||
|
input string
|
||||||
|
expect bool
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{"", false, "Empty local is not valid"},
|
||||||
|
{"a", true, "Single letter should be fine"},
|
||||||
|
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
|
||||||
|
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
|
||||||
|
{"FirstLast", true, "Mixed case permitted"},
|
||||||
|
{"user123", true, "Numbers permitted"},
|
||||||
|
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},
|
||||||
|
{"first.last", true, "Embedded period is permitted"},
|
||||||
|
{"first..last", false, "Sequence of periods is not allowed"},
|
||||||
|
{".user", false, "Cannot lead with a period"},
|
||||||
|
{"user.", false, "Cannot end with a period"},
|
||||||
|
// {"james@mail", false, "Unquoted @ not permitted"},
|
||||||
|
{"first last", false, "Unquoted space not permitted"},
|
||||||
|
{"tricky\\. ", false, "Unquoted space not permitted"},
|
||||||
|
{"no,commas", false, "Unquoted comma not allowed"},
|
||||||
|
{"t[es]t", false, "Unquoted square brackets not allowed"},
|
||||||
|
// {"james\\", false, "Cannot end with backslash quote"},
|
||||||
|
{"james\\@mail", true, "Quoted @ permitted"},
|
||||||
|
{"quoted\\ space", true, "Quoted space permitted"},
|
||||||
|
{"no\\,commas", true, "Quoted comma is OK"},
|
||||||
|
{"t\\[es\\]t", true, "Quoted brackets are OK"},
|
||||||
|
{"user\\name", true, "Should be able to quote a-z"},
|
||||||
|
{"USER\\NAME", true, "Should be able to quote A-Z"},
|
||||||
|
{"user\\1", true, "Should be able to quote a digit"},
|
||||||
|
{"one\\$\\|", true, "Should be able to quote plain specials"},
|
||||||
|
{"return\\\r", true, "Should be able to quote ASCII control chars"},
|
||||||
|
{"high\\\x80", false, "Should not accept > 7-bit quoted chars"},
|
||||||
|
{"quote\\\"", true, "Quoted double quote is permitted"},
|
||||||
|
{"\"james\"", true, "Quoted a-z is permitted"},
|
||||||
|
{"\"first last\"", true, "Quoted space is permitted"},
|
||||||
|
{"\"quoted@sign\"", true, "Quoted @ is allowed"},
|
||||||
|
{"\"qp\\\"quote\"", true, "Quoted quote within quoted string is OK"},
|
||||||
|
{"\"unterminated", false, "Quoted string must be terminated"},
|
||||||
|
{"\"unterminated\\\"", false, "Quoted string must be terminated"},
|
||||||
|
{"embed\"quote\"string", false, "Embedded quoted string is illegal"},
|
||||||
|
{"user+mailbox", true, "RFC3696 test case should be valid"},
|
||||||
|
{"customer/department=shipping", true, "RFC3696 test case should be valid"},
|
||||||
|
{"$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"},
|
||||||
|
}
|
||||||
|
for _, tt := range testTable {
|
||||||
|
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
|
||||||
|
if (err != nil) == tt.expect {
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Got error: %s", err)
|
||||||
|
}
|
||||||
|
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import "net/mail"
|
||||||
|
|
||||||
|
// Recipient represents a potential email recipient, allows policies for it to be queried.
|
||||||
|
type Recipient 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
|
||||||
|
// Mailbox is the canonical mailbox name for this recipient.
|
||||||
|
Mailbox string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAccept returns true if Inbucket should accept mail for this recipient.
|
||||||
|
func (r *Recipient) ShouldAccept() bool {
|
||||||
|
return r.addrPolicy.ShouldAcceptDomain(r.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldStore returns true if Inbucket should store mail for this recipient.
|
||||||
|
func (r *Recipient) ShouldStore() bool {
|
||||||
|
return r.addrPolicy.ShouldStoreDomain(r.Domain)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailboxListV1 renders a list of messages in a mailbox
|
||||||
|
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: name,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: msg.From.String(),
|
||||||
|
To: stringutil.StringAddressList(msg.To),
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, jmessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxShowV1 renders a particular message from a mailbox
|
||||||
|
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attachParts := msg.Attachments()
|
||||||
|
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||||
|
for i, part := range attachParts {
|
||||||
|
content := part.Content
|
||||||
|
var checksum = md5.Sum(content)
|
||||||
|
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||||
|
ContentType: part.ContentType,
|
||||||
|
FileName: part.FileName,
|
||||||
|
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
|
||||||
|
strconv.Itoa(i) + "/" + part.FileName,
|
||||||
|
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
|
||||||
|
strconv.Itoa(i) + "/" + part.FileName,
|
||||||
|
MD5: hex.EncodeToString(checksum[:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w,
|
||||||
|
&model.JSONMessageV1{
|
||||||
|
Mailbox: name,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: msg.From.String(),
|
||||||
|
To: stringutil.StringAddressList(msg.To),
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
|
Header: msg.Header(),
|
||||||
|
Body: &model.JSONMessageBodyV1{
|
||||||
|
Text: msg.Text(),
|
||||||
|
HTML: msg.HTML(),
|
||||||
|
},
|
||||||
|
Attachments: attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxMarkSeenV1 marks a message as read.
|
||||||
|
func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(req.Body)
|
||||||
|
dm := model.JSONMessageHeaderV1{}
|
||||||
|
if err := dec.Decode(&dm); err != nil {
|
||||||
|
return fmt.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if dm.Seen {
|
||||||
|
err = ctx.Manager.MarkSeen(name, id)
|
||||||
|
if err == storage.ErrNotExist {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// This doesn't indicate empty, likely an IO error
|
||||||
|
return fmt.Errorf("MarkSeen(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxPurgeV1 deletes all messages from a mailbox
|
||||||
|
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete all messages
|
||||||
|
err = ctx.Manager.PurgeMessages(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxSourceV1 displays the raw source of a message, including headers. Renders text/plain
|
||||||
|
func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r, err := ctx.Manager.SourceReader(name, id)
|
||||||
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("SourceReader(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Output message source
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxDeleteV1 removes a particular message from a mailbox
|
||||||
|
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = ctx.Manager.RemoveMessage(name, id)
|
||||||
|
if err == storage.ErrNotExist {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// This doesn't indicate missing, likely an IO error
|
||||||
|
return fmt.Errorf("RemoveMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "http://localhost/api/v1"
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
|
// Test invalid mailbox name
|
||||||
|
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
|
||||||
|
expectCode := 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test empty mailbox
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/empty")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Mailbox error
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/messageserr")
|
||||||
|
expectCode = 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test JSON message headers
|
||||||
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
meta1 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
}
|
||||||
|
meta2 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0002",
|
||||||
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 2",
|
||||||
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
|
}
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
|
|
||||||
|
// Check return code
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result []interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("Expected 2 results, got %v", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedStringEquals(t, result, "[0]/mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "[0]/id", "0001")
|
||||||
|
decodedStringEquals(t, result, "[0]/from", "<from1@host>")
|
||||||
|
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||||
|
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||||
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
|
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "[1]/id", "0002")
|
||||||
|
decodedStringEquals(t, result, "[1]/from", "<from2@host>")
|
||||||
|
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||||
|
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||||
|
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||||
|
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestMessage(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
mm := test.NewManager()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
|
// Test invalid mailbox name
|
||||||
|
w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001")
|
||||||
|
expectCode := 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test requesting a message that does not exist
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
|
||||||
|
expectCode = 404
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetMessage error
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
|
||||||
|
expectCode = 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 JSON message headers
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
msg1 := message.New(
|
||||||
|
message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
Seen: true,
|
||||||
|
},
|
||||||
|
&enmime.Envelope{
|
||||||
|
Text: "This is some text",
|
||||||
|
HTML: "This is some HTML",
|
||||||
|
Root: &enmime.Part{
|
||||||
|
Header: textproto.MIMEHeader{
|
||||||
|
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
|
||||||
|
"From": []string{"noreply@inbucket.org"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mm.AddMessage("good", msg1)
|
||||||
|
|
||||||
|
// Check return code
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/good/0001")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedStringEquals(t, result, "mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "id", "0001")
|
||||||
|
decodedStringEquals(t, result, "from", "<from1@host>")
|
||||||
|
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "subject", "subject 1")
|
||||||
|
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "size", 0)
|
||||||
|
decodedBoolEquals(t, result, "seen", true)
|
||||||
|
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||||
|
decodedStringEquals(t, result, "body/html", "This is some HTML")
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestMarkSeen(t *testing.T) {
|
||||||
|
mm := test.NewManager()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
// Create some messages.
|
||||||
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
meta1 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
}
|
||||||
|
meta2 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0002",
|
||||||
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 2",
|
||||||
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
|
}
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
|
// Mark one read.
|
||||||
|
w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`)
|
||||||
|
expectCode := 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
// Get mailbox.
|
||||||
|
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
// Check JSON.
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result []interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("Expected 2 results, got %v", len(result))
|
||||||
|
}
|
||||||
|
decodedStringEquals(t, result, "[0]/id", "0001")
|
||||||
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
|
decodedStringEquals(t, result, "[1]/id", "0002")
|
||||||
|
decodedBoolEquals(t, result, "[1]/seen", true)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/rest/model"
|
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client accesses the Inbucket REST API v1
|
// Client accesses the Inbucket REST API v1
|
||||||
@@ -58,10 +58,20 @@ func (c *Client) GetMessage(name, id string) (message *Message, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks the specified message as having been read.
|
||||||
|
func (c *Client) MarkSeen(name, id string) error {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||||
|
err := c.doJSON("PATCH", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetMessageSource returns the message source given a mailbox name and message ID.
|
// GetMessageSource returns the message source given a mailbox name and message ID.
|
||||||
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
||||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
|
||||||
resp, err := c.do("GET", uri)
|
resp, err := c.do("GET", uri, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -81,7 +91,7 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
|||||||
// DeleteMessage deletes a single message given the mailbox name and message ID.
|
// DeleteMessage deletes a single message given the mailbox name and message ID.
|
||||||
func (c *Client) DeleteMessage(name, id string) error {
|
func (c *Client) DeleteMessage(name, id string) error {
|
||||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||||
resp, err := c.do("DELETE", uri)
|
resp, err := c.do("DELETE", uri, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +105,7 @@ func (c *Client) DeleteMessage(name, id string) error {
|
|||||||
// PurgeMailbox deletes all messages in the given mailbox
|
// PurgeMailbox deletes all messages in the given mailbox
|
||||||
func (c *Client) PurgeMailbox(name string) error {
|
func (c *Client) PurgeMailbox(name string) error {
|
||||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||||
resp, err := c.do("DELETE", uri)
|
resp, err := c.do("DELETE", uri, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,32 @@ func TestClientV1GetMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientV1MarkSeen(t *testing.T) {
|
||||||
|
var want, got string
|
||||||
|
|
||||||
|
c, err := New(baseURLStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mth := &mockHTTPClient{}
|
||||||
|
c.client = mth
|
||||||
|
|
||||||
|
// Method under test
|
||||||
|
_ = c.MarkSeen("testbox", "20170107T224128-0000")
|
||||||
|
|
||||||
|
want = "PATCH"
|
||||||
|
got = mth.req.Method
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.Method == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||||
|
got = mth.req.URL.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.URL == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientV1GetMessageSource(t *testing.T) {
|
func TestClientV1GetMessageSource(t *testing.T) {
|
||||||
var want, got string
|
var want, got string
|
||||||
|
|
||||||
@@ -158,7 +184,8 @@ func TestClientV1MessageHeader(t *testing.T) {
|
|||||||
"from":"from1",
|
"from":"from1",
|
||||||
"subject":"subject1",
|
"subject":"subject1",
|
||||||
"date":"2017-01-01T00:00:00.000-07:00",
|
"date":"2017-01-01T00:00:00.000-07:00",
|
||||||
"size":100
|
"size":100,
|
||||||
|
"seen":true
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -216,6 +243,12 @@ func TestClientV1MessageHeader(t *testing.T) {
|
|||||||
t.Errorf("Subject == %q, want %q", got, want)
|
t.Errorf("Subject == %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wantb := true
|
||||||
|
gotb := header.Seen
|
||||||
|
if gotb != wantb {
|
||||||
|
t.Errorf("Seen == %v, want %v", gotb, wantb)
|
||||||
|
}
|
||||||
|
|
||||||
// Test MessageHeader.Delete()
|
// Test MessageHeader.Delete()
|
||||||
mth.body = ""
|
mth.body = ""
|
||||||
err = header.Delete()
|
err = header.Delete()
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
@@ -18,28 +20,48 @@ type restClient struct {
|
|||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// do performs an HTTP request with this client and returns the response
|
// do performs an HTTP request with this client and returns the response.
|
||||||
func (c *restClient) do(method, uri string) (*http.Response, error) {
|
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
|
||||||
rel, err := url.Parse(uri)
|
rel, err := url.Parse(uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := c.baseURL.ResolveReference(rel)
|
url := c.baseURL.ResolveReference(rel)
|
||||||
|
var r io.Reader
|
||||||
// Build the request
|
if body != nil {
|
||||||
req, err := http.NewRequest(method, url.String(), nil)
|
r = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url.String(), r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the request
|
|
||||||
return c.client.Do(req)
|
return c.client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doGet performs an HTTP request with this client and marshalls the JSON response into v
|
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
|
||||||
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||||
resp, err := c.do(method, uri)
|
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("Unexpected HTTP response status %v: %s", 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -35,17 +35,29 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error)
|
|||||||
StatusCode: m.statusCode,
|
StatusCode: m.statusCode,
|
||||||
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
|
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockHTTPClient) ReqBody() []byte {
|
||||||
|
r, err := m.req.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := ioutil.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
func TestDo(t *testing.T) {
|
func TestDo(t *testing.T) {
|
||||||
var want, got string
|
var want, got string
|
||||||
|
|
||||||
mth := &mockHTTPClient{}
|
mth := &mockHTTPClient{}
|
||||||
c := &restClient{mth, baseURL}
|
c := &restClient{mth, baseURL}
|
||||||
|
body := []byte("Test body")
|
||||||
|
|
||||||
_, err := c.do("POST", "/dopost")
|
_, err := c.do("POST", "/dopost", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,6 +73,11 @@ func TestDo(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.URL == %q, want %q", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDoJSON(t *testing.T) {
|
func TestDoJSON(t *testing.T) {
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/mail"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ type JSONMessageHeaderV1 struct {
|
|||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
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
|
||||||
@@ -25,11 +25,13 @@ type JSONMessageV1 struct {
|
|||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
Body *JSONMessageBodyV1 `json:"body"`
|
Body *JSONMessageBodyV1 `json:"body"`
|
||||||
Header mail.Header `json:"header"`
|
Header map[string][]string `json:"header"`
|
||||||
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSONMessageAttachmentV1 contains information about a MIME attachment
|
||||||
type JSONMessageAttachmentV1 struct {
|
type JSONMessageAttachmentV1 struct {
|
||||||
FileName string `json:"filename"`
|
FileName string `json:"filename"`
|
||||||
ContentType string `json:"content-type"`
|
ContentType string `json:"content-type"`
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import "github.com/gorilla/mux"
|
||||||
|
import "github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
|
|
||||||
|
// SetupRoutes populates the routes for the REST interface
|
||||||
|
func SetupRoutes(r *mux.Router) {
|
||||||
|
// API v1
|
||||||
|
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||||
|
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
||||||
|
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||||
|
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
||||||
|
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
||||||
|
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
|
||||||
|
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
||||||
|
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
|
||||||
|
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
||||||
|
r.Path("/api/v1/monitor/messages").Handler(
|
||||||
|
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||||
|
r.Path("/api/v1/monitor/messages/{name}").Handler(
|
||||||
|
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||||
|
}
|
||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/jhillyerd/inbucket/httpd"
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/log"
|
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/rest/model"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/jhillyerd/inbucket/stringutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -63,11 +62,13 @@ func (ml *msgListener) Receive(msg msghub.Message) error {
|
|||||||
|
|
||||||
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
||||||
func (ml *msgListener) 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()
|
defer ml.Close()
|
||||||
conn.SetReadLimit(maxMessageSize)
|
conn.SetReadLimit(maxMessageSize)
|
||||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
conn.SetPongHandler(func(string) error {
|
conn.SetPongHandler(func(string) error {
|
||||||
log.Tracef("HTTP[%v] Got WebSocket pong", conn.RemoteAddr())
|
slog.Debug().Msg("Got pong")
|
||||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -81,9 +82,9 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
|||||||
websocket.CloseNoStatusReceived,
|
websocket.CloseNoStatusReceived,
|
||||||
) {
|
) {
|
||||||
// Unexpected close code
|
// Unexpected close code
|
||||||
log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err)
|
slog.Warn().Err(err).Msg("Socket error")
|
||||||
} else {
|
} else {
|
||||||
log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr())
|
slog.Debug().Msg("Closing socket")
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,8 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
// Write error
|
// Write error
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Tracef("HTTP[%v] Sent WebSocket ping", conn.RemoteAddr())
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,52 +146,52 @@ func (ml *msgListener) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
|
||||||
|
// the client of all messages received.
|
||||||
func MonitorAllMessagesV1(
|
func MonitorAllMessagesV1(
|
||||||
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Upgrade to Websocket
|
// Upgrade to Websocket.
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
httpd.ExpWebSocketConnectsCurrent.Add(1)
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
httpd.ExpWebSocketConnectsCurrent.Add(-1)
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
}()
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
// Create, register listener; then interact with conn
|
|
||||||
ml := newMsgListener(ctx.MsgHub, "")
|
ml := newMsgListener(ctx.MsgHub, "")
|
||||||
go ml.WSWriter(conn)
|
go ml.WSWriter(conn)
|
||||||
ml.WSReader(conn)
|
ml.WSReader(conn)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
|
||||||
|
// notifies the client of messages received by a particular mailbox.
|
||||||
func MonitorMailboxMessagesV1(
|
func MonitorMailboxMessagesV1(
|
||||||
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Upgrade to Websocket
|
// Upgrade to Websocket.
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
httpd.ExpWebSocketConnectsCurrent.Add(1)
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
httpd.ExpWebSocketConnectsCurrent.Add(-1)
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
}()
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
// Create, register listener; then interact with conn
|
|
||||||
ml := newMsgListener(ctx.MsgHub, name)
|
ml := newMsgListener(ctx.MsgHub, name)
|
||||||
go ml.WSWriter(conn)
|
go ml.WSWriter(conn)
|
||||||
ml.WSReader(conn)
|
ml.WSReader(conn)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
web.Router.ServeHTTP(w, req)
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
|
||||||
|
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
web.Router.ServeHTTP(w, req)
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||||
|
// Capture log output
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
log.SetOutput(buf)
|
||||||
|
|
||||||
|
// Have to reset default mux to prevent duplicate routes
|
||||||
|
http.DefaultServeMux = http.NewServeMux()
|
||||||
|
cfg := &config.Root{
|
||||||
|
Web: config.Web{
|
||||||
|
UIDir: "../ui",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
shutdownChan := make(chan bool)
|
||||||
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
|
SetupRoutes(web.Router)
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedBoolEquals(t *testing.T, json interface{}, path string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got, ok := val.(bool); ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedNumberEquals(t *testing.T, json interface{}, path string, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got, ok := val.(float64); ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got, ok := val.(string); ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDecodedPath recursively navigates the specified path, returing the requested element. If
|
||||||
|
// something goes wrong, the returned string will contain an explanation.
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
//
|
||||||
|
// is equivalent to the JavaScript:
|
||||||
|
//
|
||||||
|
// o.users[1].name
|
||||||
|
//
|
||||||
|
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return o, ""
|
||||||
|
}
|
||||||
|
if o == nil {
|
||||||
|
return nil, " is nil"
|
||||||
|
}
|
||||||
|
key := path[0]
|
||||||
|
present := false
|
||||||
|
var val interface{}
|
||||||
|
if key[0] == '[' {
|
||||||
|
// Expecting slice.
|
||||||
|
index, err := strconv.Atoi(strings.Trim(key, "[]"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "/" + key + " is not a slice index"
|
||||||
|
}
|
||||||
|
oslice, ok := o.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, " is not a slice"
|
||||||
|
}
|
||||||
|
if index >= len(oslice) {
|
||||||
|
return nil, "/" + key + " is out of bounds"
|
||||||
|
}
|
||||||
|
val, present = oslice[index], true
|
||||||
|
} else {
|
||||||
|
// Expecting map.
|
||||||
|
omap, ok := o.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, " is not a map"
|
||||||
|
}
|
||||||
|
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, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State tracks the current mode of our POP3 state machine
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AUTHORIZATION state: the client must now identify and authenticate
|
||||||
|
AUTHORIZATION State = iota
|
||||||
|
// TRANSACTION state: mailbox open, client may now issue commands
|
||||||
|
TRANSACTION
|
||||||
|
// QUIT state: client requests us to end session
|
||||||
|
QUIT
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
return "AUTHORIZATION"
|
||||||
|
case TRANSACTION:
|
||||||
|
return "TRANSACTION"
|
||||||
|
case QUIT:
|
||||||
|
return "QUIT"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = map[string]bool{
|
||||||
|
"QUIT": true,
|
||||||
|
"STAT": true,
|
||||||
|
"LIST": true,
|
||||||
|
"RETR": true,
|
||||||
|
"DELE": true,
|
||||||
|
"NOOP": true,
|
||||||
|
"RSET": true,
|
||||||
|
"TOP": true,
|
||||||
|
"UIDL": true,
|
||||||
|
"USER": true,
|
||||||
|
"PASS": true,
|
||||||
|
"APOP": true,
|
||||||
|
"CAPA": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session defines an active POP3 session
|
||||||
|
type Session struct {
|
||||||
|
*Server // Reference to the server we belong to.
|
||||||
|
id int // Session ID number.
|
||||||
|
conn net.Conn // Our network connection.
|
||||||
|
remoteHost string // IP address of client.
|
||||||
|
sendError error // Used to bail out of read loop on send error.
|
||||||
|
state State // Current session state.
|
||||||
|
reader *bufio.Reader // Buffered reader for our net conn.
|
||||||
|
user string // Mailbox name.
|
||||||
|
messages []storage.Message // Slice of messages in mailbox.
|
||||||
|
retain []bool // Messages to retain upon UPDATE (true=retain).
|
||||||
|
msgCount int // Number of undeleted messages.
|
||||||
|
logger zerolog.Logger // Session specific logger.
|
||||||
|
debug bool // Print network traffic to stdout.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession creates a new POP3 session
|
||||||
|
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
return &Session{
|
||||||
|
Server: server,
|
||||||
|
id: id,
|
||||||
|
conn: conn,
|
||||||
|
state: AUTHORIZATION,
|
||||||
|
reader: reader,
|
||||||
|
remoteHost: host,
|
||||||
|
logger: logger,
|
||||||
|
debug: server.config.Debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) String() string {
|
||||||
|
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session flow:
|
||||||
|
* 1. Send initial greeting
|
||||||
|
* 2. Receive cmd
|
||||||
|
* 3. If good cmd, respond, optionally change state
|
||||||
|
* 4. If bad cmd, respond error
|
||||||
|
* 5. Goto 2
|
||||||
|
*/
|
||||||
|
func (s *Server) startSession(id int, conn net.Conn) {
|
||||||
|
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
|
||||||
|
Int("session", id).Logger()
|
||||||
|
logger.Info().Msg("Starting POP3 session")
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Closing connection")
|
||||||
|
}
|
||||||
|
s.wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssn := NewSession(s, id, conn, logger)
|
||||||
|
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
|
||||||
|
time.Now().Unix(), s.config.Domain))
|
||||||
|
|
||||||
|
// This is our command reading loop
|
||||||
|
for ssn.state != QUIT && ssn.sendError == nil {
|
||||||
|
line, err := ssn.readLine()
|
||||||
|
if err == nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands we handle in any state
|
||||||
|
switch cmd {
|
||||||
|
case "CAPA":
|
||||||
|
// List our capabilities per RFC2449
|
||||||
|
ssn.send("+OK Capability list follows")
|
||||||
|
ssn.send("TOP")
|
||||||
|
ssn.send("USER")
|
||||||
|
ssn.send("UIDL")
|
||||||
|
ssn.send("IMPLEMENTATION Inbucket")
|
||||||
|
ssn.send(".")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to handler for current state
|
||||||
|
switch ssn.state {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
ssn.authorizationHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case TRANSACTION:
|
||||||
|
ssn.transactionHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
ssn.send("-ERR Syntax error, command garbled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// readLine() returned an error
|
||||||
|
if err == io.EOF {
|
||||||
|
switch ssn.state {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
// EOF is common here
|
||||||
|
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
|
||||||
|
default:
|
||||||
|
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// not an EOF
|
||||||
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
ssn.send("-ERR Idle timeout, bye bye")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssn.send("-ERR Connection error, sorry")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn.sendError != nil {
|
||||||
|
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
|
||||||
|
}
|
||||||
|
ssn.logger.Info().Msgf("Closing connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTHORIZATION state
|
||||||
|
func (s *Session) authorizationHandler(cmd string, args []string) {
|
||||||
|
switch cmd {
|
||||||
|
case "QUIT":
|
||||||
|
s.send("+OK Goodnight and good luck")
|
||||||
|
s.enterState(QUIT)
|
||||||
|
case "USER":
|
||||||
|
if len(args) > 0 {
|
||||||
|
s.user = args[0]
|
||||||
|
s.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", s.user))
|
||||||
|
} else {
|
||||||
|
s.send("-ERR Missing username argument")
|
||||||
|
}
|
||||||
|
case "PASS":
|
||||||
|
if s.user == "" {
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
} else {
|
||||||
|
s.loadMailbox()
|
||||||
|
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
|
||||||
|
s.enterState(TRANSACTION)
|
||||||
|
}
|
||||||
|
case "APOP":
|
||||||
|
if len(args) != 2 {
|
||||||
|
s.logger.Warn().Msgf("Expected two arguments for APOP")
|
||||||
|
s.send("-ERR APOP requires two arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.user = args[0]
|
||||||
|
s.loadMailbox()
|
||||||
|
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
|
||||||
|
s.enterState(TRANSACTION)
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSACTION state
|
||||||
|
func (s *Session) transactionHandler(cmd string, args []string) {
|
||||||
|
switch cmd {
|
||||||
|
case "STAT":
|
||||||
|
if len(args) != 0 {
|
||||||
|
s.logger.Warn().Msgf("STAT got an unexpected argument")
|
||||||
|
s.send("-ERR STAT command must have no arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
var size int64
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
count++
|
||||||
|
size += msg.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", count, size))
|
||||||
|
case "LIST":
|
||||||
|
if len(args) > 1 {
|
||||||
|
s.logger.Warn().Msgf("LIST command had more than 1 argument")
|
||||||
|
s.send("-ERR LIST command must have zero or one argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was not an integer")
|
||||||
|
s.send("-ERR LIST command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was less than 1")
|
||||||
|
s.send("-ERR LIST argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was greater than number of messages")
|
||||||
|
s.send("-ERR LIST argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.retain[msgNum-1] {
|
||||||
|
s.logger.Warn().Msgf("Client tried to LIST a message it had deleted")
|
||||||
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].Size()))
|
||||||
|
} else {
|
||||||
|
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
s.send(fmt.Sprintf("%v %v", i+1, msg.Size()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
case "UIDL":
|
||||||
|
if len(args) > 1 {
|
||||||
|
s.logger.Warn().Msgf("UIDL command had more than 1 argument")
|
||||||
|
s.send("-ERR UIDL command must have zero or one argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was not an integer")
|
||||||
|
s.send("-ERR UIDL command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was less than 1")
|
||||||
|
s.send("-ERR UIDL argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was greater than number of messages")
|
||||||
|
s.send("-ERR UIDL argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.retain[msgNum-1] {
|
||||||
|
s.logger.Warn().Msgf("Client tried to UIDL a message it had deleted")
|
||||||
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].ID()))
|
||||||
|
} else {
|
||||||
|
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
s.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
case "DELE":
|
||||||
|
if len(args) != 1 {
|
||||||
|
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
|
||||||
|
s.send("-ERR DELE command requires a single argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was not an integer")
|
||||||
|
s.send("-ERR DELE command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was less than 1")
|
||||||
|
s.send("-ERR DELE argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was greater than number of messages")
|
||||||
|
s.send("-ERR DELE argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.retain[msgNum-1] {
|
||||||
|
s.retain[msgNum-1] = false
|
||||||
|
s.msgCount--
|
||||||
|
s.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
|
||||||
|
} else {
|
||||||
|
s.logger.Warn().Msgf("Client tried to DELE an already deleted message")
|
||||||
|
s.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum))
|
||||||
|
}
|
||||||
|
case "RETR":
|
||||||
|
if len(args) != 1 {
|
||||||
|
s.logger.Warn().Msgf("RETR command had invalid number of arguments")
|
||||||
|
s.send("-ERR RETR command requires a single argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was not an integer")
|
||||||
|
s.send("-ERR RETR command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was less than 1")
|
||||||
|
s.send("-ERR RETR argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was greater than number of messages")
|
||||||
|
s.send("-ERR RETR argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
|
||||||
|
s.sendMessage(s.messages[msgNum-1])
|
||||||
|
case "TOP":
|
||||||
|
if len(args) != 2 {
|
||||||
|
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
|
||||||
|
s.send("-ERR TOP command requires two arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was not an integer")
|
||||||
|
s.send("-ERR TOP command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was less than 1")
|
||||||
|
s.send("-ERR TOP first argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was greater than number of messages")
|
||||||
|
s.send("-ERR TOP first argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines int64
|
||||||
|
lines, err = strconv.ParseInt(args[1], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("TOP command second argument was not an integer")
|
||||||
|
s.send("-ERR TOP command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lines < 0 {
|
||||||
|
s.logger.Warn().Msgf("TOP command second argument was negative")
|
||||||
|
s.send("-ERR TOP second argument must be non-negative")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send("+OK Top of message follows")
|
||||||
|
s.sendMessageTop(s.messages[msgNum-1], int(lines))
|
||||||
|
case "QUIT":
|
||||||
|
s.send("+OK We will process your deletes")
|
||||||
|
s.processDeletes()
|
||||||
|
s.enterState(QUIT)
|
||||||
|
case "NOOP":
|
||||||
|
s.send("+OK I have sucessfully done nothing")
|
||||||
|
case "RSET":
|
||||||
|
// Reset session, don't actually delete anything I told you to
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on RSET request")
|
||||||
|
s.reset()
|
||||||
|
s.send("+OK Session reset")
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the contents of the message to the client
|
||||||
|
func (s *Session) sendMessage(msg storage.Message) {
|
||||||
|
reader, err := msg.Source()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to close message: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Lines starting with . must be prefixed with another .
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
s.send(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = scanner.Err(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send(".")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the headers plus the top N lines to the client
|
||||||
|
func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
|
||||||
|
reader, err := msg.Source()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to close message: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
inBody := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Lines starting with . must be prefixed with another .
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
if inBody {
|
||||||
|
// Check if we need to send anymore lines
|
||||||
|
if lineCount < 1 {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
lineCount--
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if line == "" {
|
||||||
|
// We've hit the end of the header
|
||||||
|
inBody = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = scanner.Err(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send(".")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the users mailbox
|
||||||
|
func (s *Session) loadMailbox() {
|
||||||
|
s.logger = s.logger.With().Str("mailbox", s.user).Logger()
|
||||||
|
m, err := s.store.GetMessages(s.user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to load messages for %v: %v", s.user, err)
|
||||||
|
}
|
||||||
|
s.messages = m
|
||||||
|
s.retainAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset retain flag to true for all messages
|
||||||
|
func (s *Session) retainAll() {
|
||||||
|
s.retain = make([]bool, len(s.messages))
|
||||||
|
for i := range s.retain {
|
||||||
|
s.retain[i] = true
|
||||||
|
}
|
||||||
|
s.msgCount = len(s.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This would be considered the "UPDATE" state in the RFC, but it does not fit
|
||||||
|
// with our state-machine design here, since no commands are accepted - it just
|
||||||
|
// indicates that the session was closed cleanly and that deletes should be
|
||||||
|
// processed.
|
||||||
|
func (s *Session) processDeletes() {
|
||||||
|
s.logger.Info().Msgf("Processing deletes")
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if !s.retain[i] {
|
||||||
|
s.logger.Debug().Str("id", msg.ID()).Msg("Deleting message")
|
||||||
|
if err := s.store.RemoveMessage(s.user, msg.ID()); err != nil {
|
||||||
|
s.logger.Warn().Str("id", msg.ID()).Err(err).Msg("Error deleting message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) enterState(state State) {
|
||||||
|
s.state = state
|
||||||
|
s.logger.Debug().Msgf("Entering state %v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDeadline calculates the next read or write deadline based on configured timeout.
|
||||||
|
func (s *Session) nextDeadline() time.Time {
|
||||||
|
return time.Now().Add(s.config.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send requested message, store errors in Session.sendError
|
||||||
|
func (s *Session) send(msg string) {
|
||||||
|
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d > %v\n", s.id, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a line of input
|
||||||
|
func (s *Session) readLine() (line string, err error) {
|
||||||
|
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line, err = s.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
return "", nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
words := strings.Split(line, " ")
|
||||||
|
return strings.ToUpper(words[0]), words[1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reset() {
|
||||||
|
s.retainAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ooSeq(cmd string) {
|
||||||
|
s.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd))
|
||||||
|
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/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.
|
||||||
|
globalShutdown chan bool // Inbucket shutdown signal.
|
||||||
|
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Server struct.
|
||||||
|
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
|
||||||
|
return &Server{
|
||||||
|
config: pop3Config,
|
||||||
|
store: store,
|
||||||
|
globalShutdown: shutdownChan,
|
||||||
|
wg: new(sync.WaitGroup),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server and listen for connections
|
||||||
|
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.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.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Listener go routine.
|
||||||
|
go s.serve(ctx)
|
||||||
|
// Wait for shutdown.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the listen/accept loop.
|
||||||
|
func (s *Server) serve(ctx context.Context) {
|
||||||
|
// Handle incoming connections.
|
||||||
|
var tempDelay time.Duration
|
||||||
|
for sid := 1; ; sid++ {
|
||||||
|
if conn, err := s.listener.Accept(); err != nil {
|
||||||
|
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||||
|
// Temporary error, sleep for a bit and try again.
|
||||||
|
if tempDelay == 0 {
|
||||||
|
tempDelay = 5 * time.Millisecond
|
||||||
|
} else {
|
||||||
|
tempDelay *= 2
|
||||||
|
}
|
||||||
|
if max := 1 * time.Second; tempDelay > max {
|
||||||
|
tempDelay = max
|
||||||
|
}
|
||||||
|
log.Error().Str("module", "pop3").Err(err).
|
||||||
|
Msgf("POP3 accept error; retrying in %v", tempDelay)
|
||||||
|
time.Sleep(tempDelay)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Permanent error.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// POP3 is shutting down.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Something went wrong.
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tempDelay = 0
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.startSession(sid, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) emergencyShutdown() {
|
||||||
|
// Shutdown Inbucket
|
||||||
|
select {
|
||||||
|
case _ = <-s.globalShutdown:
|
||||||
|
default:
|
||||||
|
close(s.globalShutdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain causes the caller to block until all active POP3 sessions have finished
|
||||||
|
func (s *Server) Drain() {
|
||||||
|
// Wait for sessions to close
|
||||||
|
s.wg.Wait()
|
||||||
|
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/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)"
|
||||||
|
|
||||||
|
// GREET State: Waiting for HELO
|
||||||
|
GREET State = iota
|
||||||
|
// READY State: Got HELO, waiting for MAIL
|
||||||
|
READY
|
||||||
|
// MAIL State: Got MAIL, accepting RCPTs
|
||||||
|
MAIL
|
||||||
|
// DATA State: Got DATA, waiting for "."
|
||||||
|
DATA
|
||||||
|
// QUIT State: Client requested end of session
|
||||||
|
QUIT
|
||||||
|
)
|
||||||
|
|
||||||
|
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
|
||||||
|
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||||
|
// (?:) is non-grouping sub-match
|
||||||
|
var fromRegex = regexp.MustCompile(
|
||||||
|
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case GREET:
|
||||||
|
return "GREET"
|
||||||
|
case READY:
|
||||||
|
return "READY"
|
||||||
|
case MAIL:
|
||||||
|
return "MAIL"
|
||||||
|
case DATA:
|
||||||
|
return "DATA"
|
||||||
|
case QUIT:
|
||||||
|
return "QUIT"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = map[string]bool{
|
||||||
|
"HELO": true,
|
||||||
|
"EHLO": true,
|
||||||
|
"MAIL": true,
|
||||||
|
"RCPT": true,
|
||||||
|
"DATA": true,
|
||||||
|
"RSET": true,
|
||||||
|
"SEND": true,
|
||||||
|
"SOML": true,
|
||||||
|
"SAML": true,
|
||||||
|
"VRFY": true,
|
||||||
|
"EXPN": true,
|
||||||
|
"HELP": true,
|
||||||
|
"NOOP": true,
|
||||||
|
"QUIT": true,
|
||||||
|
"TURN": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session holds the state of an SMTP session
|
||||||
|
type Session struct {
|
||||||
|
*Server // Server this session belongs to.
|
||||||
|
id int // Session ID.
|
||||||
|
conn net.Conn // TCP connection.
|
||||||
|
remoteDomain string // Remote domain from HELO command.
|
||||||
|
remoteHost string // Remote host.
|
||||||
|
sendError error // Last network send error.
|
||||||
|
state State // Session state machine.
|
||||||
|
reader *bufio.Reader // Buffered reading for TCP conn.
|
||||||
|
from string // Sender from MAIL command.
|
||||||
|
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||||
|
logger zerolog.Logger // Session specific logger.
|
||||||
|
debug bool // Print network traffic to stdout.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession creates a new Session for the given connection
|
||||||
|
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
return &Session{
|
||||||
|
Server: server,
|
||||||
|
id: id,
|
||||||
|
conn: conn,
|
||||||
|
state: GREET,
|
||||||
|
reader: reader,
|
||||||
|
remoteHost: host,
|
||||||
|
recipients: make([]*policy.Recipient, 0),
|
||||||
|
logger: logger,
|
||||||
|
debug: server.config.Debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) String() string {
|
||||||
|
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session flow:
|
||||||
|
* 1. Send initial greeting
|
||||||
|
* 2. Receive cmd
|
||||||
|
* 3. If good cmd, respond, optionally change state
|
||||||
|
* 4. If bad cmd, respond error
|
||||||
|
* 5. Goto 2
|
||||||
|
*/
|
||||||
|
func (s *Server) startSession(id int, conn net.Conn) {
|
||||||
|
logger := log.Hook(logHook{}).With().
|
||||||
|
Str("module", "smtp").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).
|
||||||
|
Int("session", id).Logger()
|
||||||
|
logger.Info().Msg("Starting SMTP session")
|
||||||
|
expConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Closing connection")
|
||||||
|
}
|
||||||
|
s.wg.Done()
|
||||||
|
expConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssn := NewSession(s, id, conn, logger)
|
||||||
|
ssn.greet()
|
||||||
|
|
||||||
|
// This is our command reading loop
|
||||||
|
for ssn.state != QUIT && ssn.sendError == nil {
|
||||||
|
if ssn.state == DATA {
|
||||||
|
// Special case, does not use SMTP command format
|
||||||
|
ssn.dataHandler()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line, err := ssn.readLine()
|
||||||
|
if err == nil {
|
||||||
|
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||||
|
// Check against valid SMTP commands
|
||||||
|
if cmd == "" {
|
||||||
|
ssn.send("500 Speak up")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !commands[cmd] {
|
||||||
|
ssn.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd))
|
||||||
|
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands we handle in any state
|
||||||
|
switch cmd {
|
||||||
|
case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN":
|
||||||
|
// These commands are not implemented in any state
|
||||||
|
ssn.send(fmt.Sprintf("502 %v command not implemented", cmd))
|
||||||
|
ssn.logger.Warn().Msgf("Command %v not implemented by Inbucket", cmd)
|
||||||
|
continue
|
||||||
|
case "VRFY":
|
||||||
|
ssn.send("252 Cannot VRFY user, but will accept message")
|
||||||
|
continue
|
||||||
|
case "NOOP":
|
||||||
|
ssn.send("250 I have sucessfully done nothing")
|
||||||
|
continue
|
||||||
|
case "RSET":
|
||||||
|
// Reset session
|
||||||
|
ssn.logger.Debug().Msgf("Resetting session state on RSET request")
|
||||||
|
ssn.reset()
|
||||||
|
ssn.send("250 Session reset")
|
||||||
|
continue
|
||||||
|
case "QUIT":
|
||||||
|
ssn.send("221 Goodnight and good luck")
|
||||||
|
ssn.enterState(QUIT)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to handler for current state
|
||||||
|
switch ssn.state {
|
||||||
|
case GREET:
|
||||||
|
ssn.greetHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case READY:
|
||||||
|
ssn.readyHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case MAIL:
|
||||||
|
ssn.mailHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
ssn.send("500 Syntax error, command garbled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// readLine() returned an error
|
||||||
|
if err == io.EOF {
|
||||||
|
switch ssn.state {
|
||||||
|
case GREET, READY:
|
||||||
|
// EOF is common here
|
||||||
|
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
|
||||||
|
default:
|
||||||
|
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// not an EOF
|
||||||
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
ssn.send("221 Idle timeout, bye bye")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssn.send("221 Connection error, sorry")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn.sendError != nil {
|
||||||
|
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
|
||||||
|
}
|
||||||
|
ssn.logger.Info().Msgf("Closing connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GREET state -> waiting for HELO
|
||||||
|
func (s *Session) greetHandler(cmd string, arg string) {
|
||||||
|
switch cmd {
|
||||||
|
case "HELO":
|
||||||
|
domain, err := parseHelloArgument(arg)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Domain/address argument required for HELO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.remoteDomain = domain
|
||||||
|
s.send("250 Great, let's get this show on the road")
|
||||||
|
s.enterState(READY)
|
||||||
|
case "EHLO":
|
||||||
|
domain, err := parseHelloArgument(arg)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Domain/address argument required for EHLO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.remoteDomain = domain
|
||||||
|
s.send("250-Great, let's get this show on the road")
|
||||||
|
s.send("250-8BITMIME")
|
||||||
|
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
||||||
|
s.enterState(READY)
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHelloArgument(arg string) (string, error) {
|
||||||
|
domain := arg
|
||||||
|
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
|
||||||
|
domain = arg[:idx]
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
return "", fmt.Errorf("Invalid domain")
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// READY state -> waiting for MAIL
|
||||||
|
func (s *Session) readyHandler(cmd string, arg string) {
|
||||||
|
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); err != nil {
|
||||||
|
s.send("501 Bad sender address syntax")
|
||||||
|
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAIL state -> waiting for RCPTs followed by DATA
|
||||||
|
func (s *Session) mailHandler(cmd string, arg string) {
|
||||||
|
switch cmd {
|
||||||
|
case "RCPT":
|
||||||
|
if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") {
|
||||||
|
s.send("501 Was expecting RCPT arg syntax of TO:<address>")
|
||||||
|
s.logger.Warn().Msgf("Bad RCPT argument: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := strings.Trim(arg[3:], "<> ")
|
||||||
|
recip, err := s.addrPolicy.NewRecipient(addr)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Bad recipient address syntax")
|
||||||
|
s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !recip.ShouldAccept() {
|
||||||
|
s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
|
||||||
|
s.send("550 Relay not permitted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s.recipients) >= s.config.MaxRecipients {
|
||||||
|
s.logger.Warn().Msgf("Limit of %v recipients exceeded", s.config.MaxRecipients)
|
||||||
|
s.send(fmt.Sprintf("552 Limit of %v recipients exceeded", s.config.MaxRecipients))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recipients = append(s.recipients, recip)
|
||||||
|
s.logger.Debug().Str("to", addr).Msg("Recipient added")
|
||||||
|
s.send(fmt.Sprintf("250 I'll make sure <%v> gets this", addr))
|
||||||
|
return
|
||||||
|
case "DATA":
|
||||||
|
if arg != "" {
|
||||||
|
s.send("501 DATA command should not have any arguments")
|
||||||
|
s.logger.Warn().Msgf("Got unexpected args on DATA: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s.recipients) == 0 {
|
||||||
|
// DATA out of sequence
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.enterState(DATA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
func (s *Session) dataHandler() {
|
||||||
|
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||||
|
msgBuf := &bytes.Buffer{}
|
||||||
|
for {
|
||||||
|
lineBuf, err := s.readByteLine()
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
s.send("221 Idle timeout, bye bye")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||||
|
s.enterState(QUIT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
|
||||||
|
// 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.
|
||||||
|
_, err := s.manager.Deliver(
|
||||||
|
recip, s.from, s.recipients, prefix, msgBuf.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)
|
||||||
|
}
|
||||||
|
s.send("250 Mail accepted for delivery")
|
||||||
|
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// RFC: remove leading periods from DATA.
|
||||||
|
if len(lineBuf) > 0 && lineBuf[0] == '.' {
|
||||||
|
lineBuf = lineBuf[1:]
|
||||||
|
}
|
||||||
|
msgBuf.Write(lineBuf)
|
||||||
|
if msgBuf.Len() > s.config.MaxMessageBytes {
|
||||||
|
s.send("552 Maximum message size exceeded")
|
||||||
|
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) enterState(state State) {
|
||||||
|
s.state = state
|
||||||
|
s.logger.Debug().Msgf("Entering state %v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) greet() {
|
||||||
|
s.send(fmt.Sprintf("220 %v Inbucket SMTP ready", s.config.Domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDeadline calculates the next read or write deadline based on configured timeout.
|
||||||
|
func (s *Session) nextDeadline() time.Time {
|
||||||
|
return time.Now().Add(s.config.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send requested message, store errors in Session.sendError
|
||||||
|
func (s *Session) send(msg string) {
|
||||||
|
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d > %v\n", s.id, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readByteLine reads a line of input, returns byte slice.
|
||||||
|
func (s *Session) readByteLine() ([]byte, error) {
|
||||||
|
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := s.reader.ReadBytes('\n')
|
||||||
|
if err == nil && s.debug {
|
||||||
|
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
|
||||||
|
}
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a line of input
|
||||||
|
func (s *Session) readLine() (line string, err error) {
|
||||||
|
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line, err = s.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
l := len(line)
|
||||||
|
switch {
|
||||||
|
case l == 0:
|
||||||
|
return "", "", true
|
||||||
|
case l < 4:
|
||||||
|
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
case l == 4:
|
||||||
|
return strings.ToUpper(line), "", true
|
||||||
|
case l == 5:
|
||||||
|
// Too long to be only command, too short to have args
|
||||||
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// If we made it here, command is long enough to have args
|
||||||
|
if line[4] != ' ' {
|
||||||
|
// There wasn't a space after the command?
|
||||||
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// I'm not sure if we should trim the args or not, but we will for now
|
||||||
|
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
// The leading space is mandatory.
|
||||||
|
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||||
|
args = make(map[string]string)
|
||||||
|
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||||
|
pm := re.FindAllStringSubmatch(arg, -1)
|
||||||
|
if pm == nil {
|
||||||
|
s.logger.Warn().Msgf("Failed to parse arg string: %q")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, m := range pm {
|
||||||
|
args[strings.ToUpper(m[1])] = m[2]
|
||||||
|
}
|
||||||
|
s.logger.Debug().Msgf("ESMTP params: %v", args)
|
||||||
|
return args, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reset() {
|
||||||
|
s.enterState(READY)
|
||||||
|
s.from = ""
|
||||||
|
s.recipients = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ooSeq(cmd string) {
|
||||||
|
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
|
||||||
|
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package smtpd
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -13,9 +12,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
@@ -25,10 +26,8 @@ type scriptStep struct {
|
|||||||
|
|
||||||
// Test commands in GREET state
|
// Test commands in GREET state
|
||||||
func TestGreetState(t *testing.T) {
|
func TestGreetState(t *testing.T) {
|
||||||
// Setup mock objects
|
ds := test.NewStore()
|
||||||
mds := &datastore.MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
|
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// Test out some mangled HELOs
|
// Test out some mangled HELOs
|
||||||
@@ -82,10 +81,8 @@ func TestGreetState(t *testing.T) {
|
|||||||
|
|
||||||
// Test commands in READY state
|
// Test commands in READY state
|
||||||
func TestReadyState(t *testing.T) {
|
func TestReadyState(t *testing.T) {
|
||||||
// Setup mock objects
|
ds := test.NewStore()
|
||||||
mds := &datastore.MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
|
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// Test out some mangled READY commands
|
// Test out some mangled READY commands
|
||||||
@@ -143,21 +140,7 @@ func TestReadyState(t *testing.T) {
|
|||||||
|
|
||||||
// Test commands in MAIL state
|
// Test commands in MAIL state
|
||||||
func TestMailState(t *testing.T) {
|
func TestMailState(t *testing.T) {
|
||||||
// Setup mock objects
|
mds := test.NewStore()
|
||||||
mds := &datastore.MockDataStore{}
|
|
||||||
mb1 := &datastore.MockMailbox{}
|
|
||||||
msg1 := &datastore.MockMessage{}
|
|
||||||
mds.On("MailboxFor", "u1").Return(mb1, nil)
|
|
||||||
mb1.On("NewMessage").Return(msg1, nil)
|
|
||||||
mb1.On("Name").Return("u1")
|
|
||||||
msg1.On("ID").Return("")
|
|
||||||
msg1.On("From").Return("")
|
|
||||||
msg1.On("To").Return(make([]string, 0))
|
|
||||||
msg1.On("Date").Return(time.Time{})
|
|
||||||
msg1.On("Subject").Return("")
|
|
||||||
msg1.On("Size").Return(0)
|
|
||||||
msg1.On("Close").Return(nil)
|
|
||||||
|
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
server, logbuf, teardown := setupSMTPServer(mds)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
@@ -186,13 +169,11 @@ func TestMailState(t *testing.T) {
|
|||||||
{"RCPT TO:<u1@gmail.com>", 250},
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
{"RCPT TO: <u2@gmail.com>", 250},
|
{"RCPT TO: <u2@gmail.com>", 250},
|
||||||
{"RCPT TO:u3@gmail.com", 250},
|
{"RCPT TO:u3@gmail.com", 250},
|
||||||
|
{"RCPT TO:u3@deny.com", 550},
|
||||||
{"RCPT TO: u4@gmail.com", 250},
|
{"RCPT TO: u4@gmail.com", 250},
|
||||||
{"RSET", 250},
|
{"RSET", 250},
|
||||||
{"MAIL FROM:<john@gmail.com>", 250},
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
{"RCPT TO:<user\\@internal@external.com", 250},
|
{`RCPT TO:<"first/last"@host.com`, 250},
|
||||||
{"RCPT TO:<\"first last\"@host.com", 250},
|
|
||||||
{"RCPT TO:<user\\>name@host.com>", 250},
|
|
||||||
{"RCPT TO:<\"user>name\"@host.com>", 250},
|
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -258,21 +239,7 @@ func TestMailState(t *testing.T) {
|
|||||||
|
|
||||||
// Test commands in DATA state
|
// Test commands in DATA state
|
||||||
func TestDataState(t *testing.T) {
|
func TestDataState(t *testing.T) {
|
||||||
// Setup mock objects
|
mds := test.NewStore()
|
||||||
mds := &datastore.MockDataStore{}
|
|
||||||
mb1 := &datastore.MockMailbox{}
|
|
||||||
msg1 := &datastore.MockMessage{}
|
|
||||||
mds.On("MailboxFor", "u1").Return(mb1, nil)
|
|
||||||
mb1.On("NewMessage").Return(msg1, nil)
|
|
||||||
mb1.On("Name").Return("u1")
|
|
||||||
msg1.On("ID").Return("")
|
|
||||||
msg1.On("From").Return("")
|
|
||||||
msg1.On("To").Return(make([]string, 0))
|
|
||||||
msg1.On("Date").Return(time.Time{})
|
|
||||||
msg1.On("Subject").Return("")
|
|
||||||
msg1.On("Size").Return(0)
|
|
||||||
msg1.On("Close").Return(nil)
|
|
||||||
|
|
||||||
server, logbuf, teardown := setupSMTPServer(mds)
|
server, logbuf, teardown := setupSMTPServer(mds)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
@@ -280,7 +247,6 @@ func TestDataState(t *testing.T) {
|
|||||||
pipe := setupSMTPSession(server)
|
pipe := setupSMTPSession(server)
|
||||||
c := textproto.NewConn(pipe)
|
c := textproto.NewConn(pipe)
|
||||||
|
|
||||||
// Get us into DATA state
|
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
t.Errorf("Expected a 220 greeting, got %v", code)
|
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
@@ -307,6 +273,33 @@ Hi!
|
|||||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test with no useful headers.
|
||||||
|
pipe = setupSMTPSession(server)
|
||||||
|
c = textproto.NewConn(pipe)
|
||||||
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
|
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||||
|
}
|
||||||
|
script = []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
|
{"DATA", 354},
|
||||||
|
}
|
||||||
|
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?
|
||||||
|
`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
if t.Failed() {
|
if t.Failed() {
|
||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
@@ -367,43 +360,41 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
|||||||
func (m *mockConn) SetReadDeadline(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 (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
|
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
|
||||||
// Test Server Config
|
cfg := &config.Root{
|
||||||
cfg := config.SMTPConfig{
|
MailboxNaming: config.FullNaming,
|
||||||
IP4address: net.IPv4(127, 0, 0, 1),
|
SMTP: config.SMTP{
|
||||||
IP4port: 2500,
|
Addr: "127.0.0.1:2500",
|
||||||
Domain: "inbucket.local",
|
Domain: "inbucket.local",
|
||||||
DomainNoStore: "bitbucket.local",
|
|
||||||
MaxRecipients: 5,
|
MaxRecipients: 5,
|
||||||
MaxIdleSeconds: 5,
|
|
||||||
MaxMessageBytes: 5000,
|
MaxMessageBytes: 5000,
|
||||||
StoreMessages: true,
|
DefaultAccept: true,
|
||||||
|
RejectDomains: []string{"deny.com"},
|
||||||
|
Timeout: 5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
// Capture log output.
|
||||||
// Capture log output
|
|
||||||
buf = new(bytes.Buffer)
|
buf = new(bytes.Buffer)
|
||||||
log.SetOutput(buf)
|
log.SetOutput(buf)
|
||||||
|
// Create a server, don't start it.
|
||||||
// Create a server, don't start it
|
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
teardown = func() {
|
teardown = func() {
|
||||||
close(shutdownChan)
|
close(shutdownChan)
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
|
addrPolicy := &policy.Addressing{Config: cfg}
|
||||||
|
manager := &message.StoreManager{Store: ds}
|
||||||
|
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
|
||||||
return s, buf, teardown
|
return s, buf, teardown
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionNum int
|
var sessionNum int
|
||||||
|
|
||||||
func setupSMTPSession(server *Server) net.Conn {
|
func setupSMTPSession(server *Server) net.Conn {
|
||||||
// Pair of pipes to communicate
|
// Pair of pipes to communicate.
|
||||||
serverConn, clientConn := net.Pipe()
|
serverConn, clientConn := net.Pipe()
|
||||||
// Start the session
|
// Start the session.
|
||||||
server.waitgroup.Add(1)
|
server.wg.Add(1)
|
||||||
sessionNum++
|
sessionNum++
|
||||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
go server.startSession(sessionNum, &mockConn{serverConn})
|
||||||
|
|
||||||
return clientConn
|
return clientConn
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"expvar"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Raw stat collectors
|
||||||
|
expConnectsTotal = new(expvar.Int)
|
||||||
|
expConnectsCurrent = new(expvar.Int)
|
||||||
|
expReceivedTotal = new(expvar.Int)
|
||||||
|
expErrorsTotal = new(expvar.Int)
|
||||||
|
expWarnsTotal = new(expvar.Int)
|
||||||
|
|
||||||
|
// History of certain stats
|
||||||
|
deliveredHist = list.New()
|
||||||
|
connectsHist = list.New()
|
||||||
|
errorsHist = list.New()
|
||||||
|
warnsHist = list.New()
|
||||||
|
|
||||||
|
// History rendered as comma delim string
|
||||||
|
expReceivedHist = new(expvar.String)
|
||||||
|
expConnectsHist = new(expvar.String)
|
||||||
|
expErrorsHist = new(expvar.String)
|
||||||
|
expWarnsHist = new(expvar.String)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m := expvar.NewMap("smtp")
|
||||||
|
m.Set("ConnectsTotal", expConnectsTotal)
|
||||||
|
m.Set("ConnectsHist", expConnectsHist)
|
||||||
|
m.Set("ConnectsCurrent", expConnectsCurrent)
|
||||||
|
m.Set("ReceivedTotal", expReceivedTotal)
|
||||||
|
m.Set("ReceivedHist", expReceivedHist)
|
||||||
|
m.Set("ErrorsTotal", expErrorsTotal)
|
||||||
|
m.Set("ErrorsHist", expErrorsHist)
|
||||||
|
m.Set("WarnsTotal", expWarnsTotal)
|
||||||
|
m.Set("WarnsHist", expWarnsHist)
|
||||||
|
metric.AddTickerFunc(func() {
|
||||||
|
expReceivedHist.Set(metric.Push(deliveredHist, expReceivedTotal))
|
||||||
|
expConnectsHist.Set(metric.Push(connectsHist, expConnectsTotal))
|
||||||
|
expErrorsHist.Set(metric.Push(errorsHist, expErrorsTotal))
|
||||||
|
expWarnsHist.Set(metric.Push(warnsHist, expWarnsTotal))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server holds the configuration and state of our SMTP server.
|
||||||
|
type Server struct {
|
||||||
|
config config.SMTP // SMTP configuration.
|
||||||
|
addrPolicy *policy.Addressing // Address policy.
|
||||||
|
globalShutdown chan bool // Shuts down Inbucket.
|
||||||
|
manager message.Manager // Used to deliver messages.
|
||||||
|
listener net.Listener // Incoming network connections.
|
||||||
|
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new Server instance with the specificed config.
|
||||||
|
func NewServer(
|
||||||
|
smtpConfig config.SMTP,
|
||||||
|
globalShutdown chan bool,
|
||||||
|
manager message.Manager,
|
||||||
|
apolicy *policy.Addressing,
|
||||||
|
) *Server {
|
||||||
|
return &Server{
|
||||||
|
config: smtpConfig,
|
||||||
|
globalShutdown: globalShutdown,
|
||||||
|
manager: manager,
|
||||||
|
addrPolicy: apolicy,
|
||||||
|
wg: new(sync.WaitGroup),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the listener and handle incoming connections.
|
||||||
|
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.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
|
||||||
|
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Listener go routine.
|
||||||
|
go s.serve(ctx)
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the listen/accept loop.
|
||||||
|
func (s *Server) serve(ctx context.Context) {
|
||||||
|
// Handle incoming connections.
|
||||||
|
var tempDelay time.Duration
|
||||||
|
for sessionID := 1; ; sessionID++ {
|
||||||
|
if conn, err := s.listener.Accept(); err != nil {
|
||||||
|
// There was an error accepting the connection.
|
||||||
|
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||||
|
// Temporary error, sleep for a bit and try again.
|
||||||
|
if tempDelay == 0 {
|
||||||
|
tempDelay = 5 * time.Millisecond
|
||||||
|
} else {
|
||||||
|
tempDelay *= 2
|
||||||
|
}
|
||||||
|
if max := 1 * time.Second; tempDelay > max {
|
||||||
|
tempDelay = max
|
||||||
|
}
|
||||||
|
log.Error().Str("module", "smtp").Err(err).
|
||||||
|
Msgf("SMTP accept error; retrying in %v", tempDelay)
|
||||||
|
time.Sleep(tempDelay)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Permanent error.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// SMTP is shutting down.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Something went wrong.
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tempDelay = 0
|
||||||
|
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()
|
||||||
|
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained")
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import "github.com/rs/zerolog"
|
||||||
|
|
||||||
|
type logHook struct{}
|
||||||
|
|
||||||
|
// Run implements a zerolog hook that updates the SMTP warning/error expvars.
|
||||||
|
func (h logHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
|
||||||
|
switch level {
|
||||||
|
case zerolog.WarnLevel:
|
||||||
|
expWarnsTotal.Add(1)
|
||||||
|
case zerolog.ErrorLevel:
|
||||||
|
expErrorsTotal.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -6,18 +6,20 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/jhillyerd/inbucket/config"
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Context is passed into every request handler function
|
// Context is passed into every request handler function
|
||||||
|
// TODO remove redundant web config
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
Session *sessions.Session
|
Session *sessions.Session
|
||||||
DataStore datastore.DataStore
|
|
||||||
MsgHub *msghub.Hub
|
MsgHub *msghub.Hub
|
||||||
WebConfig config.WebConfig
|
Manager message.Manager
|
||||||
|
RootConfig *config.Root
|
||||||
|
WebConfig config.Web
|
||||||
IsJSON bool
|
IsJSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +61,10 @@ func NewContext(req *http.Request) (*Context, error) {
|
|||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
Vars: vars,
|
Vars: vars,
|
||||||
Session: sess,
|
Session: sess,
|
||||||
DataStore: DataStore,
|
|
||||||
MsgHub: msgHub,
|
MsgHub: msgHub,
|
||||||
WebConfig: webConfig,
|
Manager: manager,
|
||||||
|
RootConfig: rootConfig,
|
||||||
|
WebConfig: rootConfig.Web,
|
||||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||||
}
|
}
|
||||||
return ctx, err
|
return ctx, err
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,13 +8,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||||
var TemplateFuncs = template.FuncMap{
|
var TemplateFuncs = template.FuncMap{
|
||||||
"friendlyTime": FriendlyTime,
|
"friendlyTime": FriendlyTime,
|
||||||
"reverse": Reverse,
|
"reverse": Reverse,
|
||||||
|
"stringsJoin": strings.Join,
|
||||||
"textToHtml": TextToHTML,
|
"textToHtml": TextToHTML,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ func Reverse(name string, things ...interface{}) string {
|
|||||||
// Grab the route
|
// Grab the route
|
||||||
u, err := Router.Get(name).URL(strs...)
|
u, err := Router.Get(name).URL(strs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Failed to reverse route: %v", err)
|
log.Error().Str("module", "web").Str("name", name).Err(err).
|
||||||
|
Msg("Failed to reverse route")
|
||||||
return "/ROUTE-ERROR"
|
return "/ROUTE-ERROR"
|
||||||
}
|
}
|
||||||
return u.Path
|
return u.Path
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,38 +1,41 @@
|
|||||||
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
|
// Package web provides the plumbing for Inbucket's web GUI and RESTful API
|
||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"expvar"
|
"expvar"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/gorilla/securecookie"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/jhillyerd/inbucket/config"
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/log"
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is a function type that handles an HTTP request in Inbucket
|
// Handler is a function type that handles an HTTP request in Inbucket
|
||||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||||
|
|
||||||
var (
|
const (
|
||||||
// DataStore is where all the mailboxes and messages live
|
staticDir = "static"
|
||||||
DataStore datastore.DataStore
|
templateDir = "templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
// msgHub holds a reference to the message pub/sub system
|
// msgHub holds a reference to the message pub/sub system
|
||||||
msgHub *msghub.Hub
|
msgHub *msghub.Hub
|
||||||
|
manager message.Manager
|
||||||
|
|
||||||
// Router is shared between httpd, webui and rest packages. It sends
|
// Router is shared between httpd, webui and rest packages. It sends
|
||||||
// incoming requests to the correct handler function
|
// incoming requests to the correct handler function
|
||||||
Router = mux.NewRouter()
|
Router = mux.NewRouter()
|
||||||
|
|
||||||
webConfig config.WebConfig
|
rootConfig *config.Root
|
||||||
server *http.Server
|
server *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
sessionStore sessions.Store
|
sessionStore sessions.Store
|
||||||
@@ -49,51 +52,55 @@ func init() {
|
|||||||
|
|
||||||
// Initialize sets up things for unit tests or the Start() method
|
// Initialize sets up things for unit tests or the Start() method
|
||||||
func Initialize(
|
func Initialize(
|
||||||
cfg config.WebConfig,
|
conf *config.Root,
|
||||||
shutdownChan chan bool,
|
shutdownChan chan bool,
|
||||||
ds datastore.DataStore,
|
mm message.Manager,
|
||||||
mh *msghub.Hub) {
|
mh *msghub.Hub) {
|
||||||
|
|
||||||
webConfig = cfg
|
rootConfig = conf
|
||||||
globalShutdown = shutdownChan
|
globalShutdown = shutdownChan
|
||||||
|
|
||||||
// NewContext() will use this DataStore for the web handlers
|
// NewContext() will use this DataStore for the web handlers
|
||||||
DataStore = ds
|
|
||||||
msgHub = mh
|
msgHub = mh
|
||||||
|
manager = mm
|
||||||
|
|
||||||
// Content Paths
|
// Content Paths
|
||||||
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
|
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
||||||
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||||
|
Msg("Web UI content mapped")
|
||||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||||
http.FileServer(http.Dir(cfg.PublicDir))))
|
http.FileServer(http.Dir(staticPath))))
|
||||||
http.Handle("/", Router)
|
http.Handle("/", Router)
|
||||||
|
|
||||||
// Session cookie setup
|
// Session cookie setup
|
||||||
if cfg.CookieAuthKey == "" {
|
if conf.Web.CookieAuthKey == "" {
|
||||||
log.Infof("HTTP generating random cookie.auth.key")
|
log.Info().Str("module", "web").Str("phase", "startup").
|
||||||
|
Msg("Generating random cookie.auth.key")
|
||||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||||
} else {
|
} else {
|
||||||
log.Tracef("HTTP using configured cookie.auth.key")
|
log.Info().Str("module", "web").Str("phase", "startup").
|
||||||
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
|
Msg("Using configured cookie.auth.key")
|
||||||
|
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins listening for HTTP requests
|
// Start begins listening for HTTP requests
|
||||||
func Start(ctx context.Context) {
|
func Start(ctx context.Context) {
|
||||||
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
|
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: addr,
|
Addr: rootConfig.Web.Addr,
|
||||||
Handler: nil,
|
Handler: nil,
|
||||||
ReadTimeout: 60 * time.Second,
|
ReadTimeout: 60 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't use ListenAndServe because it lacks a way to close the listener
|
// We don't use ListenAndServe because it lacks a way to close the listener
|
||||||
log.Infof("HTTP listening on TCP4 %v", addr)
|
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
|
||||||
|
Msg("HTTP listening on tcp4")
|
||||||
var err error
|
var err error
|
||||||
listener, err = net.Listen("tcp", addr)
|
listener, err = net.Listen("tcp", server.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("HTTP failed to start TCP4 listener")
|
||||||
emergencyShutdown()
|
emergencyShutdown()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,12 +111,14 @@ func Start(ctx context.Context) {
|
|||||||
// Wait for shutdown
|
// Wait for shutdown
|
||||||
select {
|
select {
|
||||||
case _ = <-ctx.Done():
|
case _ = <-ctx.Done():
|
||||||
log.Tracef("HTTP server shutting down on request")
|
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
|
// Closing the listener will cause the serve() go routine to exit
|
||||||
if err := listener.Close(); err != nil {
|
if err := listener.Close(); err != nil {
|
||||||
log.Errorf("Failed to close HTTP listener: %v", err)
|
log.Debug().Str("module", "web").Str("phase", "shutdown").Err(err).
|
||||||
|
Msg("Failed to close HTTP listener")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +131,8 @@ func serve(ctx context.Context) {
|
|||||||
case _ = <-ctx.Done():
|
case _ = <-ctx.Done():
|
||||||
// Nop
|
// Nop
|
||||||
default:
|
default:
|
||||||
log.Errorf("HTTP server failed: %v", err)
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("HTTP server failed")
|
||||||
emergencyShutdown()
|
emergencyShutdown()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,17 +143,19 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
// Create the context
|
// Create the context
|
||||||
ctx, err := NewContext(req)
|
ctx, err := NewContext(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("HTTP failed to create context: %v", err)
|
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ctx.Close()
|
defer ctx.Close()
|
||||||
|
|
||||||
// Run the handler, grab the error, and report it
|
// Run the handler, grab the error, and report it
|
||||||
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
|
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||||
err = h(w, req, ctx)
|
err = h(w, req, ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||||
|
Msg("Error handling request")
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package httpd
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cachedMutex sync.Mutex
|
var cachedMutex sync.Mutex
|
||||||
@@ -20,7 +19,8 @@ var cachedPartials = map[string]*template.Template{}
|
|||||||
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
||||||
t, err := ParseTemplate(name, false)
|
t, err := ParseTemplate(name, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error in template '%v': %v", name, err)
|
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||||
|
Msg("Error in template")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Expires", "-1")
|
w.Header().Set("Expires", "-1")
|
||||||
@@ -32,7 +32,8 @@ func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error
|
|||||||
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
||||||
t, err := ParseTemplate(name, true)
|
t, err := ParseTemplate(name, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error in template '%v': %v", name, err)
|
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||||
|
Msg("Error in template")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Expires", "-1")
|
w.Header().Set("Expires", "-1")
|
||||||
@@ -49,9 +50,8 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
|
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
|
||||||
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
|
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
|
||||||
log.Tracef("Parsing template %v", tempFile)
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
var t *template.Template
|
var t *template.Template
|
||||||
@@ -62,19 +62,20 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
|||||||
t, err = t.ParseFiles(tempFile)
|
t, err = t.ParseFiles(tempFile)
|
||||||
} else {
|
} else {
|
||||||
t = template.New("_base.html").Funcs(TemplateFuncs)
|
t = template.New("_base.html").Funcs(TemplateFuncs)
|
||||||
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
|
t, err = t.ParseFiles(
|
||||||
|
filepath.Join(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allows us to disable caching for theme development
|
// Allows us to disable caching for theme development
|
||||||
if webConfig.TemplateCache {
|
if rootConfig.Web.TemplateCache {
|
||||||
if partial {
|
if partial {
|
||||||
log.Tracef("Caching partial %v", name)
|
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
|
||||||
cachedTemplates[name] = t
|
cachedTemplates[name] = t
|
||||||
} else {
|
} else {
|
||||||
log.Tracef("Caching template %v", name)
|
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
|
||||||
cachedTemplates[name] = t
|
cachedTemplates[name] = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message implements Message and contains a little bit of data about a
|
||||||
|
// particular email message, and methods to retrieve the rest of it from disk.
|
||||||
|
type Message struct {
|
||||||
|
mailbox *mbox
|
||||||
|
// Stored in GOB
|
||||||
|
Fid string
|
||||||
|
Fdate time.Time
|
||||||
|
Ffrom *mail.Address
|
||||||
|
Fto []*mail.Address
|
||||||
|
Fsubject string
|
||||||
|
Fsize int64
|
||||||
|
Fseen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMessage creates a new FileMessage object and sets the Date and ID fields.
|
||||||
|
// It will also delete messages over messageCap if configured.
|
||||||
|
func (mb *mbox) newMessage() (*Message, error) {
|
||||||
|
// Load index
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete old messages over messageCap
|
||||||
|
if mb.store.messageCap > 0 {
|
||||||
|
for len(mb.messages) >= mb.store.messageCap {
|
||||||
|
log.Info().Str("module", "storage").Str("mailbox", mb.name).
|
||||||
|
Msg("Mailbox over message cap")
|
||||||
|
id := mb.messages[0].ID()
|
||||||
|
if err := mb.removeMessage(id); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("mailbox", mb.name).Str("id", id).
|
||||||
|
Err(err).Msg("Unable to delete message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
date := time.Now()
|
||||||
|
id := generateID(date)
|
||||||
|
return &Message{mailbox: mb, Fid: id, Fdate: date}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailbox returns the name of the mailbox this message resides in.
|
||||||
|
func (m *Message) Mailbox() string {
|
||||||
|
return m.mailbox.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID gets the ID of the Message
|
||||||
|
func (m *Message) ID() string {
|
||||||
|
return m.Fid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date returns the date/time this Message was received by Inbucket
|
||||||
|
func (m *Message) Date() time.Time {
|
||||||
|
return m.Fdate
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the value of the Message From header
|
||||||
|
func (m *Message) From() *mail.Address {
|
||||||
|
return m.Ffrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// To returns the value of the Message To header
|
||||||
|
func (m *Message) To() []*mail.Address {
|
||||||
|
return m.Fto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject returns the value of the Message Subject header
|
||||||
|
func (m *Message) Subject() string {
|
||||||
|
return m.Fsubject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the size of the Message on disk in bytes
|
||||||
|
func (m *Message) Size() int64 {
|
||||||
|
return m.Fsize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) rawPath() string {
|
||||||
|
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source opens the .raw portion of a Message as an io.ReadCloser
|
||||||
|
func (m *Message) Source() (reader io.ReadCloser, err error) {
|
||||||
|
file, err := os.Open(m.rawPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen returns the seen flag value.
|
||||||
|
func (m *Message) Seen() bool {
|
||||||
|
return m.Fseen
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name of index file in each mailbox
|
||||||
|
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
|
||||||
|
countChannel = make(chan int, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Start generator
|
||||||
|
go countGenerator(countChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populates the channel with numbers
|
||||||
|
func countGenerator(c chan int) {
|
||||||
|
for i := 0; true; i = (i + 1) % 10000 {
|
||||||
|
c <- i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store implements DataStore aand is the root of the mail storage
|
||||||
|
// hiearchy. It provides access to Mailbox objects
|
||||||
|
type Store struct {
|
||||||
|
hashLock storage.HashLock
|
||||||
|
path string
|
||||||
|
mailPath string
|
||||||
|
messageCap int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, fmt.Errorf("'path' parameter not specified")
|
||||||
|
}
|
||||||
|
mailPath := filepath.Join(path, "mail")
|
||||||
|
if _, err := os.Stat(mailPath); err != nil {
|
||||||
|
// 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 &Store{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage adds a message to the specified mailbox.
|
||||||
|
func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
|
||||||
|
mb := fs.mbox(m.Mailbox())
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
r, err := m.Source()
|
||||||
|
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
|
||||||
|
file, err := os.Create(fm.rawPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w := bufio.NewWriter(file)
|
||||||
|
size, err := io.Copy(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// 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
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Update the index.
|
||||||
|
fm.Fdate = m.Date()
|
||||||
|
fm.Ffrom = m.From()
|
||||||
|
fm.Fto = m.To()
|
||||||
|
fm.Fsize = size
|
||||||
|
fm.Fsubject = m.Subject()
|
||||||
|
mb.messages = append(mb.messages, fm)
|
||||||
|
if err := mb.writeIndex(); err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fm.Fid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns the messages in the named mailbox, or an error.
|
||||||
|
func (fs *Store) GetMessage(mailbox, id string) (storage.Message, error) {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.RLock()
|
||||||
|
defer mb.RUnlock()
|
||||||
|
return mb.getMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages returns the messages in the named mailbox, or an error.
|
||||||
|
func (fs *Store) GetMessages(mailbox string) ([]storage.Message, error) {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.RLock()
|
||||||
|
defer mb.RUnlock()
|
||||||
|
return mb.getMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen flags the message as having been read.
|
||||||
|
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 {
|
||||||
|
// Already marked seen.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.Fseen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mb.writeIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes a message by ID from the specified mailbox.
|
||||||
|
func (fs *Store) RemoveMessage(mailbox, id string) error {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
return mb.removeMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages deletes all messages in the named mailbox, or returns an error.
|
||||||
|
func (fs *Store) PurgeMessages(mailbox string) error {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
return mb.purge()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||||
|
// continues to return true.
|
||||||
|
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||||
|
infos1, err := ioutil.ReadDir(fs.mailPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over level 1 directories
|
||||||
|
for _, inf1 := range infos1 {
|
||||||
|
if inf1.IsDir() {
|
||||||
|
l1 := inf1.Name()
|
||||||
|
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over level 2 directories
|
||||||
|
for _, inf2 := range infos2 {
|
||||||
|
if inf2.IsDir() {
|
||||||
|
l2 := inf2.Name()
|
||||||
|
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over mailboxes
|
||||||
|
for _, inf3 := range infos3 {
|
||||||
|
if inf3.IsDir() {
|
||||||
|
mb := fs.mboxFromHash(inf3.Name())
|
||||||
|
mb.RLock()
|
||||||
|
msgs, err := mb.getMessages()
|
||||||
|
mb.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !f(msgs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mbox returns the named mailbox.
|
||||||
|
func (fs *Store) mbox(mailbox string) *mbox {
|
||||||
|
hash := stringutil.HashMailboxName(mailbox)
|
||||||
|
s1 := hash[0:3]
|
||||||
|
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,
|
||||||
|
name: mailbox,
|
||||||
|
dirName: hash,
|
||||||
|
path: path,
|
||||||
|
indexPath: indexPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mboxFromPath constructs a mailbox based on name hash.
|
||||||
|
func (fs *Store) mboxFromHash(hash string) *mbox {
|
||||||
|
s1 := hash[0:3]
|
||||||
|
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,
|
||||||
|
dirName: hash,
|
||||||
|
path: path,
|
||||||
|
indexPath: indexPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePrefix converts a Time object into the ISO style format we use
|
||||||
|
// as a prefix for message files. Note: It is used directly by unit
|
||||||
|
// tests.
|
||||||
|
func generatePrefix(date time.Time) string {
|
||||||
|
return date.Format("20060102T150405")
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateId adds a 4-digit unique number onto the end of the string
|
||||||
|
// returned by generatePrefix()
|
||||||
|
func generateID(date time.Time) string {
|
||||||
|
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuite runs storage package test suite on file store.
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
||||||
|
ds, _ := setupDataStore(conf)
|
||||||
|
destroy := func() {
|
||||||
|
teardownDataStore(ds)
|
||||||
|
}
|
||||||
|
return ds, destroy, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test directory structure created by filestore
|
||||||
|
func TestFSDirStructure(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
root := ds.path
|
||||||
|
|
||||||
|
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||||
|
mbName := "james"
|
||||||
|
|
||||||
|
// Check filestore root exists
|
||||||
|
assert.True(t, isDir(root), "Expected %q to be a directory", root)
|
||||||
|
|
||||||
|
// Check mail dir exists
|
||||||
|
expect := filepath.Join(root, "mail")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
|
||||||
|
// Check first hash section does not exist
|
||||||
|
expect = filepath.Join(root, "mail", "474")
|
||||||
|
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
|
||||||
|
|
||||||
|
// Deliver test message
|
||||||
|
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
|
||||||
|
|
||||||
|
// Check path to message exists
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
expect = filepath.Join(expect, "474ba6")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
|
||||||
|
// Check files
|
||||||
|
mbPath := expect
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
expect = filepath.Join(mbPath, id1+".raw")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Deliver second test message
|
||||||
|
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||||
|
|
||||||
|
// Check files
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
expect = filepath.Join(mbPath, id2+".raw")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Delete message
|
||||||
|
err := ds.RemoveMessage(mbName, id1)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Message should be removed
|
||||||
|
expect = filepath.Join(mbPath, id1+".raw")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Delete message
|
||||||
|
err = ds.RemoveMessage(mbName, id2)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Message should be removed
|
||||||
|
expect = filepath.Join(mbPath, id2+".raw")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
|
||||||
|
// No messages, index & maildir should be removed
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
expect = mbPath
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
|
||||||
|
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 missing files
|
||||||
|
func TestFSMissing(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
|
mbName := "fred"
|
||||||
|
subjects := []string{"a", "b", "c"}
|
||||||
|
sentIds := make([]string, len(subjects))
|
||||||
|
|
||||||
|
for i, subj := range subjects {
|
||||||
|
// Add a message
|
||||||
|
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])
|
||||||
|
assert.Nil(t, err)
|
||||||
|
fmsg := msg.(*Message)
|
||||||
|
_ = os.Remove(fmsg.rawPath())
|
||||||
|
msg, err = ds.GetMessage(mbName, sentIds[1])
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Try to read parts of message
|
||||||
|
_, err = msg.Source()
|
||||||
|
assert.Error(t, 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 Get the latest message
|
||||||
|
func TestGetLatestMessage(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
|
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||||
|
mbName := "james"
|
||||||
|
|
||||||
|
// Test empty mailbox
|
||||||
|
msg, err := ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, msg)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Deliver test message
|
||||||
|
deliverMessage(ds, mbName, "test", time.Now())
|
||||||
|
|
||||||
|
// Deliver test message 2
|
||||||
|
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||||
|
|
||||||
|
// Test get the latest message
|
||||||
|
msg, err = ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
|
||||||
|
|
||||||
|
// Deliver test message 3
|
||||||
|
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
||||||
|
|
||||||
|
msg, err = ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
||||||
|
|
||||||
|
// Test wrong id
|
||||||
|
_, err = ds.GetMessage(mbName, "wrongid")
|
||||||
|
assert.Error(t, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupDataStore creates a new FileDataStore in a temporary directory
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPresent(path string) bool {
|
||||||
|
_, err := os.Lstat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFile(path string) bool {
|
||||||
|
if fi, err := os.Lstat(path); err == nil {
|
||||||
|
return !fi.IsDir()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(path string) bool {
|
||||||
|
if fi, err := os.Lstat(path); err == nil {
|
||||||
|
return fi.IsDir()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mbox manages the mail for a specific user and correlates to a particular directory on disk.
|
||||||
|
// mbox methods are not thread safe, mbox.RWMutex must be held prior to calling.
|
||||||
|
type mbox struct {
|
||||||
|
*sync.RWMutex
|
||||||
|
store *Store
|
||||||
|
name string
|
||||||
|
dirName string
|
||||||
|
path string
|
||||||
|
indexLoaded bool
|
||||||
|
indexPath string
|
||||||
|
messages []*Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessages scans the mailbox directory for .gob files and decodes them into
|
||||||
|
// a slice of Message objects.
|
||||||
|
func (mb *mbox) getMessages() ([]storage.Message, error) {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages := make([]storage.Message, len(mb.messages))
|
||||||
|
for i, m := range mb.messages {
|
||||||
|
messages[i] = m
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessage decodes a single message by ID and returns a Message object.
|
||||||
|
func (mb *mbox) getMessage(id string) (storage.Message, error) {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "latest" && len(mb.messages) != 0 {
|
||||||
|
return mb.messages[len(mb.messages)-1], nil
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
if m.Fid == id {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, storage.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMessage deletes the message off disk and removes it from the index.
|
||||||
|
func (mb *mbox) removeMessage(id string) error {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var msg *Message
|
||||||
|
for i, m := range mb.messages {
|
||||||
|
if id == m.ID() {
|
||||||
|
msg = m
|
||||||
|
// Slice around message we are deleting
|
||||||
|
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return storage.ErrNotExist
|
||||||
|
}
|
||||||
|
if err := mb.writeIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(mb.messages) == 0 {
|
||||||
|
// This was the last message, thus writeIndex() has removed the entire
|
||||||
|
// directory; we don't need to delete the raw file.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// There are still messages in the index
|
||||||
|
log.Debug().Str("module", "storage").Str("path", msg.rawPath()).Msg("Deleting file")
|
||||||
|
return os.Remove(msg.rawPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// purge deletes all messages in this mailbox.
|
||||||
|
func (mb *mbox) purge() error {
|
||||||
|
mb.messages = mb.messages[:0]
|
||||||
|
return mb.writeIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readIndex loads the mailbox index data from disk
|
||||||
|
func (mb *mbox) readIndex() error {
|
||||||
|
// Clear message slice, open index
|
||||||
|
mb.messages = mb.messages[:0]
|
||||||
|
// Check if index exists
|
||||||
|
if _, err := os.Stat(mb.indexPath); err != nil {
|
||||||
|
// Does not exist, but that's not an error in our world
|
||||||
|
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
|
||||||
|
Msg("Index does not yet exist")
|
||||||
|
mb.indexLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, err := os.Open(mb.indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
|
||||||
|
Msg("Failed to close")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Decode gob data
|
||||||
|
dec := gob.NewDecoder(bufio.NewReader(file))
|
||||||
|
name := ""
|
||||||
|
if err = dec.Decode(&name); err != nil {
|
||||||
|
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||||
|
}
|
||||||
|
mb.name = name
|
||||||
|
for {
|
||||||
|
// Load messages until EOF
|
||||||
|
msg := &Message{}
|
||||||
|
if err = dec.Decode(msg); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||||
|
}
|
||||||
|
msg.mailbox = mb
|
||||||
|
mb.messages = append(mb.messages, msg)
|
||||||
|
}
|
||||||
|
mb.indexLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeIndex overwrites the index on disk with the current mailbox data
|
||||||
|
func (mb *mbox) writeIndex() error {
|
||||||
|
// Lock for writing
|
||||||
|
if len(mb.messages) > 0 {
|
||||||
|
// Ensure mailbox directory exists
|
||||||
|
if err := mb.createDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Open index for writing
|
||||||
|
file, err := os.Create(mb.indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
// Write each message and then flush
|
||||||
|
enc := gob.NewEncoder(writer)
|
||||||
|
if err = enc.Encode(mb.name); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
if err = enc.Encode(m); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Flush(); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
|
||||||
|
Msg("Failed to close")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No messages, delete index+maildir
|
||||||
|
log.Debug().Str("module", "storage").Str("path", mb.path).Msg("Removing mailbox")
|
||||||
|
return mb.removeDir()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
||||||
|
func (mb *mbox) createDir() error {
|
||||||
|
if _, err := os.Stat(mb.path); err != nil {
|
||||||
|
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.path).Err(err).
|
||||||
|
Msg("Failed to create directory")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDir removes the mailbox, plus empty higher level directories
|
||||||
|
func (mb *mbox) removeDir() error {
|
||||||
|
// remove mailbox dir, including index file
|
||||||
|
if err := os.RemoveAll(mb.path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// remove parents if empty
|
||||||
|
dir := filepath.Dir(mb.path)
|
||||||
|
if removeDirIfEmpty(dir) {
|
||||||
|
removeDirIfEmpty(filepath.Dir(dir))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
|
||||||
|
// Returns true if dir was removed.
|
||||||
|
func removeDirIfEmpty(path string) (removed bool) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
files, err := f.Readdirnames(0)
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(files) > 0 {
|
||||||
|
// Dir not empty
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "storage").Str("path", path).Msg("Removing dir")
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", path).Err(err).Msg("Failed to remove")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashLock holds a fixed length array of mutexes. This approach allows concurrent mailbox
|
||||||
|
// access in most cases without requiring an infinite number of mutexes.
|
||||||
|
type HashLock [4096]sync.RWMutex
|
||||||
|
|
||||||
|
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
|
||||||
|
// string of three or more characters.
|
||||||
|
func (h *HashLock) Get(hash string) *sync.RWMutex {
|
||||||
|
if len(hash) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(hash[0:3], 16, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &h[i]
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package datastore_test
|
package storage_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashLock(t *testing.T) {
|
func TestHashLock(t *testing.T) {
|
||||||
hl := &datastore.HashLock{}
|
hl := &storage.HashLock{}
|
||||||
|
|
||||||
// Invalid hashes
|
// Invalid hashes
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import "container/list"
|
||||||
|
|
||||||
|
type msgDone struct {
|
||||||
|
msg *Message
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSizeEnforcer will delete the oldest message until the entire mail store is equal to or less
|
||||||
|
// than maxSize bytes.
|
||||||
|
func (s *Store) maxSizeEnforcer(maxSize int64) {
|
||||||
|
all := &list.List{}
|
||||||
|
curSize := int64(0)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case md, ok := <-s.incoming:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Add message to all.
|
||||||
|
m := md.msg
|
||||||
|
el := all.PushBack(m)
|
||||||
|
m.el = el
|
||||||
|
curSize += int64(m.Size())
|
||||||
|
for curSize > maxSize {
|
||||||
|
// Remove oldest message.
|
||||||
|
el := all.Front()
|
||||||
|
all.Remove(el)
|
||||||
|
m := el.Value.(*Message)
|
||||||
|
if s.removeMessage(m.mailbox, m.id) != nil {
|
||||||
|
curSize -= int64(m.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(md.done)
|
||||||
|
case md, ok := <-s.remove:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove message from all.
|
||||||
|
m := md.msg
|
||||||
|
el := all.Remove(m.el)
|
||||||
|
if el != nil {
|
||||||
|
curSize -= int64(m.Size())
|
||||||
|
}
|
||||||
|
close(md.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforcerDeliver sends delivery to enforcer if configured, and waits for completion.
|
||||||
|
func (s *Store) enforcerDeliver(m *Message) {
|
||||||
|
if s.incoming != nil {
|
||||||
|
md := &msgDone{
|
||||||
|
msg: m,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.incoming <- md
|
||||||
|
<-md.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforcerRemove sends removal to enforcer if configured, and waits for completion.
|
||||||
|
func (s *Store) enforcerRemove(m *Message) {
|
||||||
|
if s.remove != nil {
|
||||||
|
md := &msgDone{
|
||||||
|
msg: m,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.remove <- md
|
||||||
|
<-md.done
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"container/list"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is a memory store message.
|
||||||
|
type Message struct {
|
||||||
|
index int
|
||||||
|
mailbox string
|
||||||
|
id string
|
||||||
|
from *mail.Address
|
||||||
|
to []*mail.Address
|
||||||
|
date time.Time
|
||||||
|
subject string
|
||||||
|
source []byte
|
||||||
|
seen bool
|
||||||
|
el *list.Element // This message in Store.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Message = &Message{}
|
||||||
|
|
||||||
|
// Mailbox returns the mailbox name.
|
||||||
|
func (m *Message) Mailbox() string { return m.mailbox }
|
||||||
|
|
||||||
|
// ID the message ID.
|
||||||
|
func (m *Message) ID() string { return m.id }
|
||||||
|
|
||||||
|
// From returns the from address.
|
||||||
|
func (m *Message) From() *mail.Address { return m.from }
|
||||||
|
|
||||||
|
// To returns the to address list.
|
||||||
|
func (m *Message) To() []*mail.Address { return m.to }
|
||||||
|
|
||||||
|
// Date returns the date received.
|
||||||
|
func (m *Message) Date() time.Time { return m.date }
|
||||||
|
|
||||||
|
// Subject returns the subject line.
|
||||||
|
func (m *Message) Subject() string { return m.subject }
|
||||||
|
|
||||||
|
// Source returns a reader for the message source.
|
||||||
|
func (m *Message) Source() (io.ReadCloser, error) {
|
||||||
|
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the message size in bytes.
|
||||||
|
func (m *Message) Size() int64 { return int64(len(m.source)) }
|
||||||
|
|
||||||
|
// Seen returns the message seen flag.
|
||||||
|
func (m *Message) Seen() bool { return m.seen }
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements an in-memory message store.
|
||||||
|
type Store struct {
|
||||||
|
sync.Mutex
|
||||||
|
boxes map[string]*mbox
|
||||||
|
cap int // Per-mailbox message cap.
|
||||||
|
incoming chan *msgDone // New messages for size enforcer.
|
||||||
|
remove chan *msgDone // Remove deleted messages from size enforcer.
|
||||||
|
}
|
||||||
|
|
||||||
|
type mbox struct {
|
||||||
|
sync.RWMutex
|
||||||
|
name string
|
||||||
|
last int
|
||||||
|
first int
|
||||||
|
messages map[string]*Message
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Store = &Store{}
|
||||||
|
|
||||||
|
// New returns an emtpy memory store.
|
||||||
|
func New(cfg config.Storage) (storage.Store, error) {
|
||||||
|
s := &Store{
|
||||||
|
boxes: make(map[string]*mbox),
|
||||||
|
cap: cfg.MailboxMsgCap,
|
||||||
|
}
|
||||||
|
if str, ok := cfg.Params["maxkb"]; ok {
|
||||||
|
maxKB, err := strconv.ParseInt(str, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse maxkb: %v", err)
|
||||||
|
}
|
||||||
|
if maxKB > 0 {
|
||||||
|
// Setup enforcer.
|
||||||
|
s.incoming = make(chan *msgDone)
|
||||||
|
s.remove = make(chan *msgDone)
|
||||||
|
go s.maxSizeEnforcer(maxKB * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage stores the message, message ID and Size will be ignored.
|
||||||
|
func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
||||||
|
r, ierr := message.Source()
|
||||||
|
if ierr != nil {
|
||||||
|
err = ierr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, ierr := ioutil.ReadAll(r)
|
||||||
|
if ierr != nil {
|
||||||
|
err = ierr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := &Message{
|
||||||
|
mailbox: message.Mailbox(),
|
||||||
|
from: message.From(),
|
||||||
|
to: message.To(),
|
||||||
|
date: message.Date(),
|
||||||
|
subject: message.Subject(),
|
||||||
|
}
|
||||||
|
s.withMailbox(message.Mailbox(), true, func(mb *mbox) {
|
||||||
|
// Generate message ID.
|
||||||
|
mb.last++
|
||||||
|
m.index = mb.last
|
||||||
|
id = strconv.Itoa(mb.last)
|
||||||
|
m.id = id
|
||||||
|
m.source = source
|
||||||
|
mb.messages[id] = m
|
||||||
|
if s.cap > 0 {
|
||||||
|
// Enforce cap.
|
||||||
|
for len(mb.messages) > s.cap {
|
||||||
|
delete(mb.messages, strconv.Itoa(mb.first))
|
||||||
|
mb.first++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s.enforcerDeliver(m)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage gets a mesage.
|
||||||
|
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
|
||||||
|
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||||
|
var ok bool
|
||||||
|
m, ok = mb.messages[id]
|
||||||
|
if !ok {
|
||||||
|
m = nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages gets a list of messages.
|
||||||
|
func (s *Store) GetMessages(mailbox string) (ms []storage.Message, err error) {
|
||||||
|
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||||
|
ms = make([]storage.Message, 0, len(mb.messages))
|
||||||
|
for _, v := range mb.messages {
|
||||||
|
ms = append(ms, v)
|
||||||
|
}
|
||||||
|
sort.Slice(ms, func(i, j int) bool {
|
||||||
|
return ms[i].(*Message).index < ms[j].(*Message).index
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return ms, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks a message as having been read.
|
||||||
|
func (s *Store) MarkSeen(mailbox, id string) error {
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
m := mb.messages[id]
|
||||||
|
if m != nil {
|
||||||
|
m.seen = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages deletes the contents of a mailbox.
|
||||||
|
func (s *Store) PurgeMessages(mailbox string) error {
|
||||||
|
var messages map[string]*Message
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
messages = mb.messages
|
||||||
|
mb.messages = make(map[string]*Message)
|
||||||
|
})
|
||||||
|
if len(messages) > 0 && s.remove != nil {
|
||||||
|
for _, m := range messages {
|
||||||
|
s.enforcerRemove(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMessage deletes a single message without notifying the size enforcer. Returns the message
|
||||||
|
// that was removed.
|
||||||
|
func (s *Store) removeMessage(mailbox, id string) *Message {
|
||||||
|
var m *Message
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
m = mb.messages[id]
|
||||||
|
if m != nil {
|
||||||
|
delete(mb.messages, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes a single message.
|
||||||
|
func (s *Store) RemoveMessage(mailbox, id string) error {
|
||||||
|
m := s.removeMessage(mailbox, id)
|
||||||
|
if m != nil {
|
||||||
|
s.enforcerRemove(m)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitMailboxes visits each mailbox in the store.
|
||||||
|
func (s *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||||
|
// Lock store, get names of all mailboxes.
|
||||||
|
s.Lock()
|
||||||
|
boxNames := make([]string, 0, len(s.boxes))
|
||||||
|
for k := range s.boxes {
|
||||||
|
boxNames = append(boxNames, k)
|
||||||
|
}
|
||||||
|
s.Unlock()
|
||||||
|
// Process mailboxes.
|
||||||
|
for _, mailbox := range boxNames {
|
||||||
|
ms, _ := s.GetMessages(mailbox)
|
||||||
|
if !f(ms) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMailbox gets or creates a mailbox, locks it, then calls f.
|
||||||
|
func (s *Store) withMailbox(mailbox string, writeLock bool, f func(mb *mbox)) {
|
||||||
|
s.Lock()
|
||||||
|
mb, ok := s.boxes[mailbox]
|
||||||
|
if !ok {
|
||||||
|
// Create mailbox
|
||||||
|
mb = &mbox{
|
||||||
|
name: mailbox,
|
||||||
|
messages: make(map[string]*Message),
|
||||||
|
}
|
||||||
|
s.boxes[mailbox] = mb
|
||||||
|
}
|
||||||
|
s.Unlock()
|
||||||
|
if writeLock {
|
||||||
|
mb.Lock()
|
||||||
|
} else {
|
||||||
|
mb.RLock()
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if writeLock {
|
||||||
|
mb.Unlock()
|
||||||
|
} else {
|
||||||
|
mb.RUnlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f(mb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuite runs storage package test suite on file store.
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
||||||
|
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) {
|
||||||
|
maxSize := int64(2048)
|
||||||
|
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}})
|
||||||
|
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
|
||||||
|
n := 10
|
||||||
|
// total := 50
|
||||||
|
sizeChan := make(chan int64, len(boxes))
|
||||||
|
// Populate mailboxes concurrently.
|
||||||
|
for _, mailbox := range boxes {
|
||||||
|
go func(mailbox string) {
|
||||||
|
size := int64(0)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
|
||||||
|
size += nbytes
|
||||||
|
}
|
||||||
|
sizeChan <- size
|
||||||
|
}(mailbox)
|
||||||
|
}
|
||||||
|
// Wait for sizes.
|
||||||
|
sentBytesTotal := int64(0)
|
||||||
|
for range boxes {
|
||||||
|
sentBytesTotal += <-sizeChan
|
||||||
|
}
|
||||||
|
// Calculate actual size.
|
||||||
|
gotSize := int64(0)
|
||||||
|
s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||||
|
for _, m := range messages {
|
||||||
|
gotSize += m.Size()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// Verify state. Messages are ~75 bytes each.
|
||||||
|
if gotSize < 2048-75 {
|
||||||
|
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
for _, mailbox := range boxes {
|
||||||
|
go func(mailbox string) {
|
||||||
|
err := s.PurgeMessages(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}(mailbox)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
count := 0
|
||||||
|
s.VisitMailboxes(func(messages []storage.Message) bool {
|
||||||
|
count += len(messages)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Got %v total messages, want: %v", count, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package datastore
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
@@ -6,8 +6,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/log"
|
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -18,14 +19,17 @@ var (
|
|||||||
expRetentionDeletesTotal = new(expvar.Int)
|
expRetentionDeletesTotal = new(expvar.Int)
|
||||||
expRetentionPeriod = new(expvar.Int)
|
expRetentionPeriod = new(expvar.Int)
|
||||||
expRetainedCurrent = new(expvar.Int)
|
expRetainedCurrent = new(expvar.Int)
|
||||||
|
expRetainedSize = new(expvar.Int)
|
||||||
|
|
||||||
// History of certain stats
|
// History of certain stats
|
||||||
retentionDeletesHist = list.New()
|
retentionDeletesHist = list.New()
|
||||||
retainedHist = list.New()
|
retainedHist = list.New()
|
||||||
|
sizeHist = list.New()
|
||||||
|
|
||||||
// History rendered as comma delimited string
|
// History rendered as comma delimited string
|
||||||
expRetentionDeletesHist = new(expvar.String)
|
expRetentionDeletesHist = new(expvar.String)
|
||||||
expRetainedHist = new(expvar.String)
|
expRetainedHist = new(expvar.String)
|
||||||
|
expSizeHist = new(expvar.String)
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -36,10 +40,13 @@ func init() {
|
|||||||
rm.Set("Period", expRetentionPeriod)
|
rm.Set("Period", expRetentionPeriod)
|
||||||
rm.Set("RetainedHist", expRetainedHist)
|
rm.Set("RetainedHist", expRetainedHist)
|
||||||
rm.Set("RetainedCurrent", expRetainedCurrent)
|
rm.Set("RetainedCurrent", expRetainedCurrent)
|
||||||
|
rm.Set("RetainedSize", expRetainedSize)
|
||||||
|
rm.Set("SizeHist", expSizeHist)
|
||||||
|
|
||||||
log.AddTickerFunc(func() {
|
metric.AddTickerFunc(func() {
|
||||||
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
|
expRetentionDeletesHist.Set(metric.Push(retentionDeletesHist, expRetentionDeletesTotal))
|
||||||
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
|
expRetainedHist.Set(metric.Push(retainedHist, expRetainedCurrent))
|
||||||
|
expSizeHist.Set(metric.Push(sizeHist, expRetainedSize))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,40 +54,44 @@ func init() {
|
|||||||
type RetentionScanner struct {
|
type RetentionScanner struct {
|
||||||
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
||||||
retentionShutdown chan bool // Closed after the scanner has shut down
|
retentionShutdown chan bool // Closed after the scanner has shut down
|
||||||
ds DataStore
|
ds Store
|
||||||
retentionPeriod time.Duration
|
retentionPeriod time.Duration
|
||||||
retentionSleep time.Duration
|
retentionSleep time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRetentionScanner launches a go-routine that scans for expired
|
// NewRetentionScanner configures a new RententionScanner.
|
||||||
// messages, following the configured interval
|
func NewRetentionScanner(
|
||||||
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
|
cfg config.Storage,
|
||||||
cfg := config.GetDataStoreConfig()
|
ds Store,
|
||||||
|
shutdownChannel chan bool,
|
||||||
|
) *RetentionScanner {
|
||||||
rs := &RetentionScanner{
|
rs := &RetentionScanner{
|
||||||
globalShutdown: shutdownChannel,
|
globalShutdown: shutdownChannel,
|
||||||
retentionShutdown: make(chan bool),
|
retentionShutdown: make(chan bool),
|
||||||
ds: ds,
|
ds: ds,
|
||||||
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
|
retentionPeriod: cfg.RetentionPeriod,
|
||||||
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
|
retentionSleep: cfg.RetentionSleep,
|
||||||
}
|
}
|
||||||
// expRetentionPeriod is displayed on the status page
|
// expRetentionPeriod is displayed on the status page
|
||||||
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
|
expRetentionPeriod.Set(int64(cfg.RetentionPeriod / time.Second))
|
||||||
return rs
|
return rs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start up the retention scanner if retention period > 0
|
// Start up the retention scanner if retention period > 0
|
||||||
func (rs *RetentionScanner) Start() {
|
func (rs *RetentionScanner) Start() {
|
||||||
if rs.retentionPeriod <= 0 {
|
if rs.retentionPeriod <= 0 {
|
||||||
log.Infof("Retention scanner disabled")
|
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled")
|
||||||
close(rs.retentionShutdown)
|
close(rs.retentionShutdown)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("Retention configured for %v", rs.retentionPeriod)
|
log.Info().Str("phase", "startup").Str("module", "storage").
|
||||||
|
Msgf("Retention configured for %v", rs.retentionPeriod)
|
||||||
go rs.run()
|
go rs.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// run loops to kick off the scanner on the correct schedule
|
// run loops to kick off the scanner on the correct schedule
|
||||||
func (rs *RetentionScanner) run() {
|
func (rs *RetentionScanner) run() {
|
||||||
|
slog := log.With().Str("module", "storage").Logger()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
retentionLoop:
|
retentionLoop:
|
||||||
for {
|
for {
|
||||||
@@ -88,7 +99,7 @@ retentionLoop:
|
|||||||
since := time.Since(start)
|
since := time.Since(start)
|
||||||
if since < time.Minute {
|
if since < time.Minute {
|
||||||
dur := time.Minute - since
|
dur := time.Minute - since
|
||||||
log.Tracef("Retention scanner sleeping for %v", dur)
|
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
|
||||||
select {
|
select {
|
||||||
case <-rs.globalShutdown:
|
case <-rs.globalShutdown:
|
||||||
break retentionLoop
|
break retentionLoop
|
||||||
@@ -97,8 +108,8 @@ retentionLoop:
|
|||||||
}
|
}
|
||||||
// Kickoff scan
|
// Kickoff scan
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
if err := rs.doScan(); err != nil {
|
if err := rs.DoScan(); err != nil {
|
||||||
log.Errorf("Error during retention scan: %v", err)
|
slog.Error().Err(err).Msg("Error during retention scan")
|
||||||
}
|
}
|
||||||
// Check for global shutdown
|
// Check for global shutdown
|
||||||
select {
|
select {
|
||||||
@@ -107,56 +118,54 @@ retentionLoop:
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Tracef("Retention scanner shut down")
|
slog.Debug().Str("phase", "shutdown").Msg("Retention scanner shut down")
|
||||||
close(rs.retentionShutdown)
|
close(rs.retentionShutdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doScan does a single pass of all mailboxes looking for messages that can be purged
|
// DoScan does a single pass of all mailboxes looking for messages that can be purged.
|
||||||
func (rs *RetentionScanner) doScan() error {
|
func (rs *RetentionScanner) DoScan() error {
|
||||||
log.Tracef("Starting retention scan")
|
slog := log.With().Str("module", "storage").Logger()
|
||||||
|
slog.Debug().Msg("Starting retention scan")
|
||||||
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
||||||
mboxes, err := rs.ds.AllMailboxes()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
retained := 0
|
retained := 0
|
||||||
// Loop over all mailboxes
|
storeSize := int64(0)
|
||||||
for _, mb := range mboxes {
|
// Loop over all mailboxes.
|
||||||
messages, err := mb.GetMessages()
|
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Loop over all messages in mailbox
|
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
if msg.Date().Before(cutoff) {
|
if msg.Date().Before(cutoff) {
|
||||||
log.Tracef("Purging expired message %v", msg.ID())
|
slog.Debug().Str("mailbox", msg.Mailbox()).
|
||||||
err = msg.Delete()
|
Msgf("Purging expired message %v", msg.ID())
|
||||||
if err != nil {
|
if err := rs.ds.RemoveMessage(msg.Mailbox(), msg.ID()); err != nil {
|
||||||
// Log but don't abort
|
slog.Error().Str("mailbox", msg.Mailbox()).Err(err).
|
||||||
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
|
Msgf("Failed to purge message %v", msg.ID())
|
||||||
} else {
|
} else {
|
||||||
expRetentionDeletesTotal.Add(1)
|
expRetentionDeletesTotal.Add(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
retained++
|
retained++
|
||||||
|
storeSize += msg.Size()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sleep after completing a mailbox
|
|
||||||
select {
|
select {
|
||||||
case <-rs.globalShutdown:
|
case <-rs.globalShutdown:
|
||||||
log.Tracef("Retention scan aborted due to shutdown")
|
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
|
||||||
return nil
|
return false
|
||||||
case <-time.After(rs.retentionSleep):
|
case <-time.After(rs.retentionSleep):
|
||||||
// Reduce disk thrashing
|
// Reduce disk thrashing
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// Update metrics
|
// Update metrics
|
||||||
setRetentionScanCompleted(time.Now())
|
setRetentionScanCompleted(time.Now())
|
||||||
expRetainedCurrent.Set(int64(retained))
|
expRetainedCurrent.Set(int64(retained))
|
||||||
|
expRetainedSize.Set(storeSize)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join does not retun until the retention scanner has shut down
|
// Join does not return until the retention scanner has shut down.
|
||||||
func (rs *RetentionScanner) Join() {
|
func (rs *RetentionScanner) Join() {
|
||||||
if rs.retentionShutdown != nil {
|
if rs.retentionShutdown != nil {
|
||||||
<-rs.retentionShutdown
|
<-rs.retentionShutdown
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package storage_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/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)
|
||||||
|
new3 := stubMessage("mb3", 2)
|
||||||
|
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)
|
||||||
|
// Test 4 hour retention
|
||||||
|
cfg := config.Storage{
|
||||||
|
RetentionPeriod: 239 * time.Minute,
|
||||||
|
RetentionSleep: 0,
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
t.Errorf("Expected %v to be deleted, was present", m.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubMessage creates a message stub of a specific age
|
||||||
|
func stubMessage(mailbox string, ageHours int) storage.Message {
|
||||||
|
return &message.Delivery{
|
||||||
|
Meta: message.Metadata{
|
||||||
|
Mailbox: mailbox,
|
||||||
|
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
|
||||||
|
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package storage contains implementation independent datastore logic
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotExist indicates the requested message does not exist.
|
||||||
|
ErrNotExist = errors.New("message does not exist")
|
||||||
|
|
||||||
|
// ErrNotWritable indicates the message is closed; no longer writable
|
||||||
|
ErrNotWritable = errors.New("Message not writable")
|
||||||
|
|
||||||
|
// Constructors tracks registered storage constructors
|
||||||
|
Constructors = make(map[string]func(config.Storage) (Store, error))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the interface Inbucket uses to interact with storage implementations.
|
||||||
|
type Store interface {
|
||||||
|
// AddMessage stores the message, message ID and Size will be ignored.
|
||||||
|
AddMessage(message Message) (id string, err error)
|
||||||
|
GetMessage(mailbox, id string) (Message, error)
|
||||||
|
GetMessages(mailbox string) ([]Message, error)
|
||||||
|
MarkSeen(mailbox, id string) error
|
||||||
|
PurgeMessages(mailbox string) error
|
||||||
|
RemoveMessage(mailbox, id string) error
|
||||||
|
VisitMailboxes(f func([]Message) (cont bool)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a message to be stored, or returned from a storage implementation.
|
||||||
|
type Message interface {
|
||||||
|
Mailbox() string
|
||||||
|
ID() string
|
||||||
|
From() *mail.Address
|
||||||
|
To() []*mail.Address
|
||||||
|
Date() time.Time
|
||||||
|
Subject() string
|
||||||
|
Source() (io.ReadCloser, error)
|
||||||
|
Size() int64
|
||||||
|
Seen() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromConfig creates an instance of the Store based on the provided configuration.
|
||||||
|
func FromConfig(c config.Storage) (store Store, err error) {
|
||||||
|
if cf := Constructors[c.Type]; cf != nil {
|
||||||
|
return cf(c)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package stringutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as
|
||||||
|
// the directory to house the mailbox
|
||||||
|
func HashMailboxName(mailbox string) string {
|
||||||
|
h := sha1.New()
|
||||||
|
if _, err := io.WriteString(h, mailbox); err != nil {
|
||||||
|
// This shouldn't ever happen
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringAddressList converts a list of addresses to a list of strings
|
||||||
|
func StringAddressList(addrs []*mail.Address) []string {
|
||||||
|
s := make([]string, len(addrs))
|
||||||
|
for i, a := range addrs {
|
||||||
|
if a != nil {
|
||||||
|
s[i] = a.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliceContains returns true if s is present in slice.
|
||||||
|
func SliceContains(slice []string, s string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if s == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliceToLower lowercases the contents of slice of strings.
|
||||||
|
func SliceToLower(slice []string) {
|
||||||
|
for i, s := range slice {
|
||||||
|
slice[i] = strings.ToLower(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user