diff --git a/.gitignore b/.gitignore index 52aeb50..dc71557 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,12 @@ _testmain.go *.swo # our binaries +/client +/client.exe /inbucket /inbucket.exe /dist/** /cmd/client/client /cmd/client/client.exe +/cmd/inbucket/inbucket +/cmd/inbucket/inbucket.exe diff --git a/.goreleaser.yml b/.goreleaser.yml index f8fe52c..03764a7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,14 +1,17 @@ project_name: inbucket + release: github: owner: jhillyerd name: inbucket name_template: '{{.Tag}}' + brew: commit_author: name: goreleaserbot email: goreleaser@carlosbecker.com install: bin.install "" + builds: - binary: inbucket goos: @@ -20,9 +23,9 @@ builds: - amd64 goarm: - "6" - main: . + main: ./cmd/inbucket ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} - - binary: client + - binary: inbucket-client goos: - darwin - freebsd @@ -34,6 +37,7 @@ builds: - "6" main: ./cmd/client ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + archive: format: tar.gz wrap_in_directory: true @@ -46,15 +50,31 @@ archive: - LICENSE* - README* - CHANGELOG* - - inbucket.bat - etc/**/* - - themes/**/* -fpm: - bindir: /usr/local/bin + - ui/**/* + +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: name_template: SNAPSHOT-{{ .Commit }} + checksum: name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt' + dist: dist + sign: artifacts: none diff --git a/.travis.yml b/.travis.yml index b9ea160..f45a552 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,14 +2,14 @@ language: go sudo: false env: - - DEPLOY_WITH_MAJOR="1.9" + - DEPLOY_WITH_MAJOR="1.10" before_script: - go get github.com/golang/lint/golint + - make deps go: - - 1.9.x - - "1.10" + - "1.10.x" deploy: provider: script diff --git a/CHANGELOG.md b/CHANGELOG.md index 7874198..278a868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,52 @@ Change Log All notable changes to this project will be documented in this file. 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: `/` 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 ### Fixed - - Adding additional locking during message delivery to prevent race condition that could lose messages. @@ -112,6 +154,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). specific message. [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.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 diff --git a/Dockerfile b/Dockerfile index cebab97..9a1e0b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,43 @@ -# Docker build file for Inbucket, see https://www.docker.io/ -# Inbucket website: http://www.inbucket.org/ +# Docker build file for Inbucket: https://www.inbucket.org/ -FROM golang:1.9-alpine -MAINTAINER James Hillyerd, @jameshillyerd +# Build +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) -ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket -ENV INBUCKET_HOME /opt/inbucket -WORKDIR $INBUCKET_HOME -ENTRYPOINT ["/con/context/start-inbucket.sh"] -CMD ["/con/configuration/inbucket.conf"] +# Run in minimal image +FROM alpine:3.7 +ENV SRC /go/src/github.com/jhillyerd/inbucket +WORKDIR /opt/inbucket +RUN mkdir bin defaults ui +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 -EXPOSE 10025 10080 10110 +EXPOSE 2500 9000 1100 -# Persistent Volumes, following convention at: -# https://github.com/docker/docker/issues/9277 -# NOTE /con/context is also used, not exposed by default -VOLUME /con/configuration -VOLUME /con/data +# Persistent Volumes +VOLUME /config +VOLUME /storage -# Build Inbucket -COPY . $INBUCKET_SRC/ -RUN "$INBUCKET_SRC/etc/docker/install.sh" +ENTRYPOINT ["/start-inbucket.sh"] +CMD ["-logjson"] diff --git a/Makefile b/Makefile index 8101866..857fca5 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,28 @@ -PKG := inbucket -SHELL := /bin/sh +SHELL = /bin/sh 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: - go clean + go clean $(PKGS) + rm -f $(commands) + rm -rf dist deps: go get -t ./... -build: clean deps - go build +build: $(commands) -install: build - go install - -test: clean deps +test: go test -race ./... fmt: @@ -31,5 +33,8 @@ simplify: lint: @test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'" - @golint -set_exit_status $${PKGS} - @go vet $${PKGS} + @golint -set_exit_status $(PKGS) + @go vet $(PKGS) + +reflex: + reflex -r '\.go$$' -- sh -c 'echo; date; echo; go test ./... && echo ALL PASS' diff --git a/README.md b/README.md index 287d6cc..a76c3eb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ to contribute code to the project check out [CONTRIBUTING.md]. ## 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], 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: - 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 Unix and OS X machines as is. Launch the daemon: diff --git a/cmd/client/list.go b/cmd/client/list.go index eda64bb..82f69d8 100644 --- a/cmd/client/list.go +++ b/cmd/client/list.go @@ -6,7 +6,7 @@ import ( "fmt" "github.com/google/subcommands" - "github.com/jhillyerd/inbucket/rest/client" + "github.com/jhillyerd/inbucket/pkg/rest/client" ) type listCmd struct { diff --git a/cmd/client/match.go b/cmd/client/match.go index df49a87..21a4ad0 100644 --- a/cmd/client/match.go +++ b/cmd/client/match.go @@ -10,7 +10,7 @@ import ( "time" "github.com/google/subcommands" - "github.com/jhillyerd/inbucket/rest/client" + "github.com/jhillyerd/inbucket/pkg/rest/client" ) type matchCmd struct { diff --git a/cmd/client/mbox.go b/cmd/client/mbox.go index cf2b46e..2105d24 100644 --- a/cmd/client/mbox.go +++ b/cmd/client/mbox.go @@ -7,7 +7,7 @@ import ( "os" "github.com/google/subcommands" - "github.com/jhillyerd/inbucket/rest/client" + "github.com/jhillyerd/inbucket/pkg/rest/client" ) type mboxCmd struct { diff --git a/cmd/inbucket/main.go b/cmd/inbucket/main.go new file mode 100644 index 0000000..30152c9 --- /dev/null +++ b/cmd/inbucket/main.go @@ -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) +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 01e909e..0000000 --- a/config/config.go +++ /dev/null @@ -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 -} diff --git a/datastore/datastore.go b/datastore/datastore.go deleted file mode 100644 index a9bcb57..0000000 --- a/datastore/datastore.go +++ /dev/null @@ -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 -} diff --git a/datastore/lock.go b/datastore/lock.go deleted file mode 100644 index a08dacc..0000000 --- a/datastore/lock.go +++ /dev/null @@ -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] -} diff --git a/datastore/retention_test.go b/datastore/retention_test.go deleted file mode 100644 index c357f7e..0000000 --- a/datastore/retention_test.go +++ /dev/null @@ -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 -} diff --git a/datastore/testing.go b/datastore/testing.go deleted file mode 100644 index 8e0799c..0000000 --- a/datastore/testing.go +++ /dev/null @@ -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) -} diff --git a/doc/config.md b/doc/config.md new file mode 100644 index 0000000..1342cb7 --- /dev/null +++ b/doc/config.md @@ -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 diff --git a/etc/dev-start.sh b/etc/dev-start.sh new file mode 100755 index 0000000..a35db19 --- /dev/null +++ b/etc/dev-start.sh @@ -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 $* diff --git a/etc/devel.conf b/etc/devel.conf deleted file mode 100644 index 1e765d4..0000000 --- a/etc/devel.conf +++ /dev/null @@ -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 diff --git a/etc/docker/defaults/inbucket.conf b/etc/docker/defaults/inbucket.conf deleted file mode 100644 index 8825e87..0000000 --- a/etc/docker/defaults/inbucket.conf +++ /dev/null @@ -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 diff --git a/etc/docker/defaults/start-inbucket.sh b/etc/docker/defaults/start-inbucket.sh index 3f30e6b..9960437 100755 --- a/etc/docker/defaults/start-inbucket.sh +++ b/etc/docker/defaults/start-inbucket.sh @@ -2,8 +2,9 @@ # start-inbucket.sh # description: start inbucket (runs within a docker container) +INBUCKET_HOME="/opt/inbucket" CONF_SOURCE="$INBUCKET_HOME/defaults" -CONF_TARGET="/con/configuration" +CONF_TARGET="/config" set -eo pipefail @@ -18,7 +19,6 @@ install_default_config() { fi } -install_default_config "inbucket.conf" install_default_config "greeting.html" exec "$INBUCKET_HOME/bin/inbucket" $* diff --git a/etc/docker/install.sh b/etc/docker/install.sh deleted file mode 100755 index 916c745..0000000 --- a/etc/docker/install.sh +++ /dev/null @@ -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" diff --git a/etc/homebrew/inbucket.conf b/etc/homebrew/inbucket.conf deleted file mode 100644 index bebd42a..0000000 --- a/etc/homebrew/inbucket.conf +++ /dev/null @@ -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 diff --git a/etc/inbucket.conf b/etc/inbucket.conf deleted file mode 100644 index e8e16d4..0000000 --- a/etc/inbucket.conf +++ /dev/null @@ -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 diff --git a/etc/linux/inbucket.service b/etc/linux/inbucket.service new file mode 100644 index 0000000..a919eb9 --- /dev/null +++ b/etc/linux/inbucket.service @@ -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 diff --git a/etc/redhat/README b/etc/redhat/README deleted file mode 100644 index 26bac7a..0000000 --- a/etc/redhat/README +++ /dev/null @@ -1,3 +0,0 @@ -Please see the RedHat installation guide on our website: - -http://www.inbucket.org/installation/redhat.html diff --git a/etc/redhat/httpd-vhost.conf b/etc/redhat/httpd-vhost.conf deleted file mode 100644 index 02eff5c..0000000 --- a/etc/redhat/httpd-vhost.conf +++ /dev/null @@ -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 - - ServerName SERVERFQDN - ProxyRequests off - - - Order allow,deny - Allow from all - - - RewriteRule ^/$ http://SERVERFQDN:9000 - ProxyPass / http://SERVERFQDN:9000/ - ProxyPassReverse / http://SERVERFQDN:9000/ - diff --git a/etc/redhat/inbucket-init.sh b/etc/redhat/inbucket-init.sh deleted file mode 100755 index 504a3a7..0000000 --- a/etc/redhat/inbucket-init.sh +++ /dev/null @@ -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 $? diff --git a/etc/redhat/inbucket.logrotate b/etc/redhat/inbucket.logrotate deleted file mode 100644 index 04ec439..0000000 --- a/etc/redhat/inbucket.logrotate +++ /dev/null @@ -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 -} diff --git a/etc/redhat/inbucket.service b/etc/redhat/inbucket.service deleted file mode 100644 index 5480a45..0000000 --- a/etc/redhat/inbucket.service +++ /dev/null @@ -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 diff --git a/swaks-tests/README.md b/etc/swaks-tests/README.md similarity index 100% rename from swaks-tests/README.md rename to etc/swaks-tests/README.md diff --git a/swaks-tests/favicon.png b/etc/swaks-tests/favicon.png similarity index 100% rename from swaks-tests/favicon.png rename to etc/swaks-tests/favicon.png diff --git a/swaks-tests/gmail.raw b/etc/swaks-tests/gmail.raw similarity index 100% rename from swaks-tests/gmail.raw rename to etc/swaks-tests/gmail.raw diff --git a/swaks-tests/mime-html.raw b/etc/swaks-tests/mime-html.raw similarity index 100% rename from swaks-tests/mime-html.raw rename to etc/swaks-tests/mime-html.raw diff --git a/swaks-tests/nonmime-html-inlined.raw b/etc/swaks-tests/nonmime-html-inlined.raw similarity index 100% rename from swaks-tests/nonmime-html-inlined.raw rename to etc/swaks-tests/nonmime-html-inlined.raw diff --git a/swaks-tests/nonmime-html-responsive.raw b/etc/swaks-tests/nonmime-html-responsive.raw similarity index 100% rename from swaks-tests/nonmime-html-responsive.raw rename to etc/swaks-tests/nonmime-html-responsive.raw diff --git a/swaks-tests/nonmime-html.raw b/etc/swaks-tests/nonmime-html.raw similarity index 100% rename from swaks-tests/nonmime-html.raw rename to etc/swaks-tests/nonmime-html.raw diff --git a/swaks-tests/outlook.raw b/etc/swaks-tests/outlook.raw similarity index 100% rename from swaks-tests/outlook.raw rename to etc/swaks-tests/outlook.raw diff --git a/swaks-tests/run-tests.sh b/etc/swaks-tests/run-tests.sh similarity index 100% rename from swaks-tests/run-tests.sh rename to etc/swaks-tests/run-tests.sh diff --git a/swaks-tests/text.txt b/etc/swaks-tests/text.txt similarity index 100% rename from swaks-tests/text.txt rename to etc/swaks-tests/text.txt diff --git a/swaks-tests/utf8-subject.raw b/etc/swaks-tests/utf8-subject.raw similarity index 100% rename from swaks-tests/utf8-subject.raw rename to etc/swaks-tests/utf8-subject.raw diff --git a/etc/ubuntu/README b/etc/ubuntu/README deleted file mode 100644 index f2559bd..0000000 --- a/etc/ubuntu/README +++ /dev/null @@ -1,3 +0,0 @@ -Please see the Ubuntu installation guide on our website: - -http://www.inbucket.org/installation/ubuntu.html diff --git a/etc/ubuntu/inbucket-upstart.conf b/etc/ubuntu/inbucket-upstart.conf deleted file mode 100644 index 810b6a9..0000000 --- a/etc/ubuntu/inbucket-upstart.conf +++ /dev/null @@ -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 diff --git a/etc/ubuntu/inbucket.logrotate b/etc/ubuntu/inbucket.logrotate deleted file mode 100644 index 04ec439..0000000 --- a/etc/ubuntu/inbucket.logrotate +++ /dev/null @@ -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 -} diff --git a/etc/ubuntu/inbucket.service b/etc/ubuntu/inbucket.service deleted file mode 100644 index 5480a45..0000000 --- a/etc/ubuntu/inbucket.service +++ /dev/null @@ -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 diff --git a/etc/unix-sample.conf b/etc/unix-sample.conf deleted file mode 100644 index 240923f..0000000 --- a/etc/unix-sample.conf +++ /dev/null @@ -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 diff --git a/etc/win-sample.conf b/etc/win-sample.conf deleted file mode 100644 index 398dbd0..0000000 --- a/etc/win-sample.conf +++ /dev/null @@ -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 diff --git a/filestore/fmessage.go b/filestore/fmessage.go deleted file mode 100644 index 54da127..0000000 --- a/filestore/fmessage.go +++ /dev/null @@ -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()) -} diff --git a/filestore/fstore.go b/filestore/fstore.go deleted file mode 100644 index 7b15f27..0000000 --- a/filestore/fstore.go +++ /dev/null @@ -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) -} diff --git a/filestore/fstore_test.go b/filestore/fstore_test.go deleted file mode 100644 index 9cac985..0000000 --- a/filestore/fstore_test.go +++ /dev/null @@ -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 -} diff --git a/inbucket.bat b/inbucket.bat deleted file mode 100644 index 0737139..0000000 --- a/inbucket.bat +++ /dev/null @@ -1 +0,0 @@ -inbucket.exe etc\win-sample.conf \ No newline at end of file diff --git a/inbucket.go b/inbucket.go deleted file mode 100644 index 23c646a..0000000 --- a/inbucket.go +++ /dev/null @@ -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] :") - 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) -} diff --git a/log/logging.go b/log/logging.go deleted file mode 100644 index 89ab7e7..0000000 --- a/log/logging.go +++ /dev/null @@ -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() -} diff --git a/log/stdout_unix.go b/log/stdout_unix.go deleted file mode 100644 index 1197844..0000000 --- a/log/stdout_unix.go +++ /dev/null @@ -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) - } -} diff --git a/log/stdout_windows.go b/log/stdout_windows.go deleted file mode 100644 index ad8d829..0000000 --- a/log/stdout_windows.go +++ /dev/null @@ -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 - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..3071907 --- /dev/null +++ b/pkg/config/config.go @@ -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() +} diff --git a/pkg/message/manager.go b/pkg/message/manager.go new file mode 100644 index 0000000..6370ea6 --- /dev/null +++ b/pkg/message/manager.go @@ -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(), + } +} diff --git a/pkg/message/message.go b/pkg/message/message.go new file mode 100644 index 0000000..8c97cda --- /dev/null +++ b/pkg/message/message.go @@ -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 +} diff --git a/log/metrics.go b/pkg/metric/metric.go similarity index 74% rename from log/metrics.go rename to pkg/metric/metric.go index c16f1e8..566445d 100644 --- a/log/metrics.go +++ b/pkg/metric/metric.go @@ -1,4 +1,4 @@ -package log +package metric import ( "container/list" @@ -7,7 +7,7 @@ import ( "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() var tickerFuncChan = make(chan TickerFunc) @@ -22,10 +22,10 @@ func AddTickerFunc(f TickerFunc) { 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 // 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()) if history.Len() > 61 { history.Remove(history.Front()) @@ -33,18 +33,7 @@ func PushMetric(history *list.List, ev expvar.Var) string { return joinStringList(history) } -// 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, ",") -} - +// metricsTicker calls the current list of TickerFuncs once per minute. func metricsTicker() { funcs := make([]TickerFunc, 0) 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, ",") +} diff --git a/msghub/hub.go b/pkg/msghub/hub.go similarity index 100% rename from msghub/hub.go rename to pkg/msghub/hub.go diff --git a/msghub/hub_test.go b/pkg/msghub/hub_test.go similarity index 100% rename from msghub/hub_test.go rename to pkg/msghub/hub_test.go diff --git a/stringutil/utils.go b/pkg/policy/address.go similarity index 58% rename from stringutil/utils.go rename to pkg/policy/address.go index 450ae1f..29de48d 100644 --- a/stringutil/utils.go +++ b/pkg/policy/address.go @@ -1,58 +1,112 @@ -package stringutil +package policy import ( "bytes" - "crypto/sha1" "fmt" - "io" + "net/mail" "strings" + + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/stringutil" ) -// 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 +// Addressing handles email address policy. +type Addressing struct { + Config *config.Root } -// 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 "" +// ExtractMailbox extracts the mailbox name from a partial email address. +func (a *Addressing) ExtractMailbox(address string) (string, error) { + local, domain, err := parseEmailAddress(address) + if err != nil { + return "", err } - return fmt.Sprintf("%x", h.Sum(nil)) + 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 } -// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035 +// NewRecipient parses an address into a Recipient. +func (a *Addressing) NewRecipient(address string) (*Recipient, error) { + local, domain, err := ParseEmailAddress(address) + if err != nil { + return nil, err + } + 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 +} + +// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain. +func (a *Addressing) ShouldAcceptDomain(domain string) bool { + domain = strings.ToLower(domain) + if a.Config.SMTP.DefaultAccept && + !stringutil.SliceContains(a.Config.SMTP.RejectDomains, domain) { + return true + } + if !a.Config.SMTP.DefaultAccept && + stringutil.SliceContains(a.Config.SMTP.AcceptDomains, domain) { + return true + } + return false +} + +// 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 { if len(domain) == 0 { return false @@ -66,22 +120,21 @@ func ValidateDomainPart(domain string) bool { prev := '.' labelLen := 0 hasAlphaNum := false - for _, c := range domain { switch { case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('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 labelLen++ case c == '-': if prev == '.' { - // Cannot lead with hyphen + // Cannot lead with hyphen. return false } case c == '.': if prev == '.' || prev == '-' { - // Cannot end with hyphen or double-dot + // Cannot end with hyphen or double-dot. return false } if labelLen > 63 { @@ -93,19 +146,18 @@ func ValidateDomainPart(domain string) bool { labelLen = 0 hasAlphaNum = false default: - // Unknown character + // Unknown character. return false } prev = c } - return true } -// ParseEmailAddress unescapes an email address, and splits the local part from the domain part. -// An error is returned if the local or domain parts fail validation following the guidelines -// in RFC3696. -func ParseEmailAddress(address string) (local string, domain string, err error) { +// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An +// error is returned if the local part fails validation following the guidelines in RFC3696. The +// domain part is optional and not validated. +func parseEmailAddress(address string) (local string, domain string, err error) { if address == "" { return "", "", fmt.Errorf("Empty address") } @@ -118,8 +170,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error) if address[0] == '.' { 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) prev := byte('.') inCharQuote := false @@ -129,30 +180,30 @@ LOOP: c := address[i] switch { case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'): - // Letters are OK + // Letters are OK. err = buf.WriteByte(c) if err != nil { return } inCharQuote = false case '0' <= c && c <= '9': - // Numbers are OK + // Numbers are OK. err = buf.WriteByte(c) if err != nil { return } inCharQuote = false case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0: - // These specials can be used unquoted + // These specials can be used unquoted. err = buf.WriteByte(c) if err != nil { return } inCharQuote = false case c == '.': - // A single period is OK + // A single period is OK. if prev == '.' { - // Sequence of periods is not permitted + // Sequence of periods is not permitted. return "", "", fmt.Errorf("Sequence of periods is not permitted") } err = buf.WriteByte(c) @@ -186,7 +237,7 @@ LOOP: } inCharQuote = false } else { - // End of local-part + // End of local-part. if i > 128 { return "", "", fmt.Errorf("Local part must not exceed 128 characters") } @@ -217,10 +268,34 @@ LOOP: if inStringQuote { 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 } + +// 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 +} diff --git a/pkg/policy/address_test.go b/pkg/policy/address_test.go new file mode 100644 index 0000000..2956dd3 --- /dev/null +++ b/pkg/policy/address_test.go @@ -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) + } + } +} diff --git a/pkg/policy/recipient.go b/pkg/policy/recipient.go new file mode 100644 index 0000000..860eebe --- /dev/null +++ b/pkg/policy/recipient.go @@ -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) +} diff --git a/pkg/rest/apiv1_controller.go b/pkg/rest/apiv1_controller.go new file mode 100644 index 0000000..d9795eb --- /dev/null +++ b/pkg/rest/apiv1_controller.go @@ -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") +} diff --git a/pkg/rest/apiv1_controller_test.go b/pkg/rest/apiv1_controller_test.go new file mode 100644 index 0000000..885ef3c --- /dev/null +++ b/pkg/rest/apiv1_controller_test.go @@ -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", "") + decodedStringEquals(t, result, "[0]/to/[0]", "") + 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", "") + decodedStringEquals(t, result, "[1]/to/[0]", "") + 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", "") + decodedStringEquals(t, result, "to/[0]", "") + 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) + } +} diff --git a/rest/client/apiv1_client.go b/pkg/rest/client/apiv1_client.go similarity index 89% rename from rest/client/apiv1_client.go rename to pkg/rest/client/apiv1_client.go index d75bfa3..d88afce 100644 --- a/rest/client/apiv1_client.go +++ b/pkg/rest/client/apiv1_client.go @@ -8,7 +8,7 @@ import ( "net/url" "time" - "github.com/jhillyerd/inbucket/rest/model" + "github.com/jhillyerd/inbucket/pkg/rest/model" ) // Client accesses the Inbucket REST API v1 @@ -58,10 +58,20 @@ func (c *Client) GetMessage(name, id string) (message *Message, err error) { 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. func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) { 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 { 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. func (c *Client) DeleteMessage(name, id string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id - resp, err := c.do("DELETE", uri) + resp, err := c.do("DELETE", uri, nil) if err != nil { return err } @@ -95,7 +105,7 @@ func (c *Client) DeleteMessage(name, id string) error { // PurgeMailbox deletes all messages in the given mailbox func (c *Client) PurgeMailbox(name string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) - resp, err := c.do("DELETE", uri) + resp, err := c.do("DELETE", uri, nil) if err != nil { return err } diff --git a/rest/client/apiv1_client_test.go b/pkg/rest/client/apiv1_client_test.go similarity index 90% rename from rest/client/apiv1_client_test.go rename to pkg/rest/client/apiv1_client_test.go index abce5ca..d3c8ca7 100644 --- a/rest/client/apiv1_client_test.go +++ b/pkg/rest/client/apiv1_client_test.go @@ -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) { var want, got string @@ -158,7 +184,8 @@ func TestClientV1MessageHeader(t *testing.T) { "from":"from1", "subject":"subject1", "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) } + wantb := true + gotb := header.Seen + if gotb != wantb { + t.Errorf("Seen == %v, want %v", gotb, wantb) + } + // Test MessageHeader.Delete() mth.body = "" err = header.Delete() diff --git a/rest/client/rest.go b/pkg/rest/client/rest.go similarity index 50% rename from rest/client/rest.go rename to pkg/rest/client/rest.go index 718749d..1cefd55 100644 --- a/rest/client/rest.go +++ b/pkg/rest/client/rest.go @@ -1,8 +1,10 @@ package client import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" "net/url" ) @@ -18,28 +20,48 @@ type restClient struct { baseURL *url.URL } -// do performs an HTTP request with this client and returns the response -func (c *restClient) do(method, uri string) (*http.Response, error) { +// do performs an HTTP request with this client and returns the response. +func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) { rel, err := url.Parse(uri) if err != nil { return nil, err } - url := c.baseURL.ResolveReference(rel) - - // Build the request - req, err := http.NewRequest(method, url.String(), nil) + var r io.Reader + if body != nil { + r = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url.String(), r) if err != nil { return nil, err } - - // Send the request 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 { - 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 { return err } diff --git a/rest/client/rest_test.go b/pkg/rest/client/rest_test.go similarity index 85% rename from rest/client/rest_test.go rename to pkg/rest/client/rest_test.go index 3668578..c4d8f6e 100644 --- a/rest/client/rest_test.go +++ b/pkg/rest/client/rest_test.go @@ -35,17 +35,29 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) StatusCode: m.statusCode, Body: ioutil.NopCloser(bytes.NewBufferString(m.body)), } - 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) { var want, got string - mth := &mockHTTPClient{} c := &restClient{mth, baseURL} + body := []byte("Test body") - _, err := c.do("POST", "/dopost") + _, err := c.do("POST", "/dopost", body) if err != nil { t.Fatal(err) } @@ -61,6 +73,11 @@ func TestDo(t *testing.T) { if got != want { t.Errorf("req.URL == %q, want %q", got, want) } + + b := mth.ReqBody() + if !bytes.Equal(b, body) { + t.Errorf("req.Body == %q, want %q", b, body) + } } func TestDoJSON(t *testing.T) { diff --git a/rest/model/apiv1_model.go b/pkg/rest/model/apiv1_model.go similarity index 86% rename from rest/model/apiv1_model.go rename to pkg/rest/model/apiv1_model.go index f2d3e76..2b18e13 100644 --- a/rest/model/apiv1_model.go +++ b/pkg/rest/model/apiv1_model.go @@ -1,7 +1,6 @@ package model import ( - "net/mail" "time" ) @@ -14,6 +13,7 @@ type JSONMessageHeaderV1 struct { Subject string `json:"subject"` Date time.Time `json:"date"` Size int64 `json:"size"` + Seen bool `json:"seen"` } // JSONMessageV1 contains the same data as the header plus a JSONMessageBody @@ -25,11 +25,13 @@ type JSONMessageV1 struct { Subject string `json:"subject"` Date time.Time `json:"date"` Size int64 `json:"size"` + Seen bool `json:"seen"` Body *JSONMessageBodyV1 `json:"body"` - Header mail.Header `json:"header"` + Header map[string][]string `json:"header"` Attachments []*JSONMessageAttachmentV1 `json:"attachments"` } +// JSONMessageAttachmentV1 contains information about a MIME attachment type JSONMessageAttachmentV1 struct { FileName string `json:"filename"` ContentType string `json:"content-type"` diff --git a/pkg/rest/routes.go b/pkg/rest/routes.go new file mode 100644 index 0000000..d622f3d --- /dev/null +++ b/pkg/rest/routes.go @@ -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") +} diff --git a/rest/socketv1_controller.go b/pkg/rest/socketv1_controller.go similarity index 69% rename from rest/socketv1_controller.go rename to pkg/rest/socketv1_controller.go index 78bec2d..7c5b019 100644 --- a/rest/socketv1_controller.go +++ b/pkg/rest/socketv1_controller.go @@ -5,11 +5,10 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/jhillyerd/inbucket/httpd" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/msghub" - "github.com/jhillyerd/inbucket/rest/model" - "github.com/jhillyerd/inbucket/stringutil" + "github.com/jhillyerd/inbucket/pkg/msghub" + "github.com/jhillyerd/inbucket/pkg/rest/model" + "github.com/jhillyerd/inbucket/pkg/server/web" + "github.com/rs/zerolog/log" ) 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 func (ml *msgListener) WSReader(conn *websocket.Conn) { + slog := log.With().Str("module", "rest").Str("proto", "WebSocket"). + Str("remote", conn.RemoteAddr().String()).Logger() defer ml.Close() conn.SetReadLimit(maxMessageSize) conn.SetReadDeadline(time.Now().Add(pongWait)) 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)) return nil }) @@ -81,9 +82,9 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) { websocket.CloseNoStatusReceived, ) { // Unexpected close code - log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err) + slog.Warn().Err(err).Msg("Socket error") } else { - log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr()) + slog.Debug().Msg("Closing socket") } break } @@ -128,7 +129,8 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) { // Write error 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( - w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Upgrade to Websocket + w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + // Upgrade to Websocket. conn, err := upgrader.Upgrade(w, req, nil) if err != nil { return err } - httpd.ExpWebSocketConnectsCurrent.Add(1) + web.ExpWebSocketConnectsCurrent.Add(1) defer func() { _ = conn.Close() - httpd.ExpWebSocketConnectsCurrent.Add(-1) + web.ExpWebSocketConnectsCurrent.Add(-1) }() - - log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr) - - // Create, register listener; then interact with conn + log.Debug().Str("module", "rest").Str("proto", "WebSocket"). + Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket") + // Create, register listener; then interact with conn. ml := newMsgListener(ctx.MsgHub, "") go ml.WSWriter(conn) ml.WSReader(conn) - 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( - w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) + w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } - // Upgrade to Websocket + // Upgrade to Websocket. conn, err := upgrader.Upgrade(w, req, nil) if err != nil { return err } - httpd.ExpWebSocketConnectsCurrent.Add(1) + web.ExpWebSocketConnectsCurrent.Add(1) defer func() { _ = conn.Close() - httpd.ExpWebSocketConnectsCurrent.Add(-1) + web.ExpWebSocketConnectsCurrent.Add(-1) }() - - log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr) - - // Create, register listener; then interact with conn + log.Debug().Str("module", "rest").Str("proto", "WebSocket"). + Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket") + // Create, register listener; then interact with conn. ml := newMsgListener(ctx.MsgHub, name) go ml.WSWriter(conn) ml.WSReader(conn) - return nil } diff --git a/pkg/rest/testutils_test.go b/pkg/rest/testutils_test.go new file mode 100644 index 0000000..67b7075 --- /dev/null +++ b/pkg/rest/testutils_test.go @@ -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, "" +} diff --git a/pkg/server/pop3/handler.go b/pkg/server/pop3/handler.go new file mode 100644 index 0000000..11c80df --- /dev/null +++ b/pkg/server/pop3/handler.go @@ -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) +} diff --git a/pkg/server/pop3/listener.go b/pkg/server/pop3/listener.go new file mode 100644 index 0000000..c9a652f --- /dev/null +++ b/pkg/server/pop3/listener.go @@ -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") +} diff --git a/pkg/server/smtp/handler.go b/pkg/server/smtp/handler.go new file mode 100644 index 0000000..ea0a801 --- /dev/null +++ b/pkg/server/smtp/handler.go @@ -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:
") + 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:
") + 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 .") + 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) +} diff --git a/smtpd/handler_test.go b/pkg/server/smtp/handler_test.go similarity index 79% rename from smtpd/handler_test.go rename to pkg/server/smtp/handler_test.go index e541f4e..66a7ac9 100644 --- a/smtpd/handler_test.go +++ b/pkg/server/smtp/handler_test.go @@ -1,8 +1,7 @@ -package smtpd +package smtp import ( "bytes" - "context" "fmt" "io" @@ -13,9 +12,11 @@ import ( "testing" "time" - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/msghub" + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/message" + "github.com/jhillyerd/inbucket/pkg/policy" + "github.com/jhillyerd/inbucket/pkg/storage" + "github.com/jhillyerd/inbucket/pkg/test" ) type scriptStep struct { @@ -25,10 +26,8 @@ type scriptStep struct { // Test commands in GREET state func TestGreetState(t *testing.T) { - // Setup mock objects - mds := &datastore.MockDataStore{} - - server, logbuf, teardown := setupSMTPServer(mds) + ds := test.NewStore() + server, logbuf, teardown := setupSMTPServer(ds) defer teardown() // Test out some mangled HELOs @@ -82,10 +81,8 @@ func TestGreetState(t *testing.T) { // Test commands in READY state func TestReadyState(t *testing.T) { - // Setup mock objects - mds := &datastore.MockDataStore{} - - server, logbuf, teardown := setupSMTPServer(mds) + ds := test.NewStore() + server, logbuf, teardown := setupSMTPServer(ds) defer teardown() // Test out some mangled READY commands @@ -143,21 +140,7 @@ func TestReadyState(t *testing.T) { // Test commands in MAIL state func TestMailState(t *testing.T) { - // Setup mock objects - 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) - + mds := test.NewStore() server, logbuf, teardown := setupSMTPServer(mds) defer teardown() @@ -186,13 +169,11 @@ func TestMailState(t *testing.T) { {"RCPT TO:", 250}, {"RCPT TO: ", 250}, {"RCPT TO:u3@gmail.com", 250}, + {"RCPT TO:u3@deny.com", 550}, {"RCPT TO: u4@gmail.com", 250}, {"RSET", 250}, {"MAIL FROM:", 250}, - {"RCPT TO:name@host.com>", 250}, - {"RCPT TO:<\"user>name\"@host.com>", 250}, + {`RCPT TO:<"first/last"@host.com`, 250}, } if err := playSession(t, server, script); err != nil { t.Error(err) @@ -258,21 +239,7 @@ func TestMailState(t *testing.T) { // Test commands in DATA state func TestDataState(t *testing.T) { - // Setup mock objects - 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) - + mds := test.NewStore() server, logbuf, teardown := setupSMTPServer(mds) defer teardown() @@ -280,7 +247,6 @@ func TestDataState(t *testing.T) { pipe := setupSMTPSession(server) c := textproto.NewConn(pipe) - // Get us into DATA state if code, _, err := c.ReadCodeLine(220); err != nil { t.Errorf("Expected a 220 greeting, got %v", code) } @@ -307,6 +273,33 @@ Hi! 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:", 250}, + {"RCPT TO:", 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() { // Wait for handler to finish logging 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) SetWriteDeadline(t time.Time) error { return nil } -func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) { - // Test Server Config - cfg := config.SMTPConfig{ - IP4address: net.IPv4(127, 0, 0, 1), - IP4port: 2500, - Domain: "inbucket.local", - DomainNoStore: "bitbucket.local", - MaxRecipients: 5, - MaxIdleSeconds: 5, - MaxMessageBytes: 5000, - StoreMessages: true, +func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) { + cfg := &config.Root{ + MailboxNaming: config.FullNaming, + SMTP: config.SMTP{ + Addr: "127.0.0.1:2500", + Domain: "inbucket.local", + MaxRecipients: 5, + MaxMessageBytes: 5000, + DefaultAccept: true, + RejectDomains: []string{"deny.com"}, + Timeout: 5, + }, } - - // Capture log output + // Capture log output. buf = new(bytes.Buffer) log.SetOutput(buf) - - // Create a server, don't start it + // Create a server, don't start it. shutdownChan := make(chan bool) - ctx, cancel := context.WithCancel(context.Background()) teardown = func() { 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 } var sessionNum int func setupSMTPSession(server *Server) net.Conn { - // Pair of pipes to communicate + // Pair of pipes to communicate. serverConn, clientConn := net.Pipe() - // Start the session - server.waitgroup.Add(1) + // Start the session. + server.wg.Add(1) sessionNum++ go server.startSession(sessionNum, &mockConn{serverConn}) - return clientConn } diff --git a/pkg/server/smtp/listener.go b/pkg/server/smtp/listener.go new file mode 100644 index 0000000..9161d75 --- /dev/null +++ b/pkg/server/smtp/listener.go @@ -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") +} diff --git a/pkg/server/smtp/loghook.go b/pkg/server/smtp/loghook.go new file mode 100644 index 0000000..59f69da --- /dev/null +++ b/pkg/server/smtp/loghook.go @@ -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) + } +} diff --git a/httpd/context.go b/pkg/server/web/context.go similarity index 66% rename from httpd/context.go rename to pkg/server/web/context.go index 6a54541..65e6b3d 100644 --- a/httpd/context.go +++ b/pkg/server/web/context.go @@ -1,4 +1,4 @@ -package httpd +package web import ( "net/http" @@ -6,19 +6,21 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/msghub" + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/message" + "github.com/jhillyerd/inbucket/pkg/msghub" ) // Context is passed into every request handler function +// TODO remove redundant web config type Context struct { - Vars map[string]string - Session *sessions.Session - DataStore datastore.DataStore - MsgHub *msghub.Hub - WebConfig config.WebConfig - IsJSON bool + Vars map[string]string + Session *sessions.Session + MsgHub *msghub.Hub + Manager message.Manager + RootConfig *config.Root + WebConfig config.Web + IsJSON bool } // Close the Context (currently does nothing) @@ -57,12 +59,13 @@ func NewContext(req *http.Request) (*Context, error) { err = nil } ctx := &Context{ - Vars: vars, - Session: sess, - DataStore: DataStore, - MsgHub: msgHub, - WebConfig: webConfig, - IsJSON: headerMatch(req, "Accept", "application/json"), + Vars: vars, + Session: sess, + MsgHub: msgHub, + Manager: manager, + RootConfig: rootConfig, + WebConfig: rootConfig.Web, + IsJSON: headerMatch(req, "Accept", "application/json"), } return ctx, err } diff --git a/httpd/helpers.go b/pkg/server/web/helpers.go similarity index 91% rename from httpd/helpers.go rename to pkg/server/web/helpers.go index a5f66c6..ea32cb2 100644 --- a/httpd/helpers.go +++ b/pkg/server/web/helpers.go @@ -1,4 +1,4 @@ -package httpd +package web import ( "fmt" @@ -8,13 +8,14 @@ import ( "strings" "time" - "github.com/jhillyerd/inbucket/log" + "github.com/rs/zerolog/log" ) // TemplateFuncs declares functions made available to all templates (including partials) var TemplateFuncs = template.FuncMap{ "friendlyTime": FriendlyTime, "reverse": Reverse, + "stringsJoin": strings.Join, "textToHtml": TextToHTML, } @@ -42,7 +43,8 @@ func Reverse(name string, things ...interface{}) string { // Grab the route u, err := Router.Get(name).URL(strs...) 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 u.Path diff --git a/httpd/helpers_test.go b/pkg/server/web/helpers_test.go similarity index 98% rename from httpd/helpers_test.go rename to pkg/server/web/helpers_test.go index fe0ca5c..39dc6eb 100644 --- a/httpd/helpers_test.go +++ b/pkg/server/web/helpers_test.go @@ -1,4 +1,4 @@ -package httpd +package web import ( "html/template" diff --git a/httpd/rest.go b/pkg/server/web/rest.go similarity index 96% rename from httpd/rest.go rename to pkg/server/web/rest.go index 805bbc9..aa9d864 100644 --- a/httpd/rest.go +++ b/pkg/server/web/rest.go @@ -1,4 +1,4 @@ -package httpd +package web import ( "encoding/json" diff --git a/httpd/server.go b/pkg/server/web/server.go similarity index 57% rename from httpd/server.go rename to pkg/server/web/server.go index f89b9ff..a60735a 100644 --- a/httpd/server.go +++ b/pkg/server/web/server.go @@ -1,38 +1,41 @@ -// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API -package httpd +// Package web provides the plumbing for Inbucket's web GUI and RESTful API +package web import ( "context" "expvar" - "fmt" "net" "net/http" + "path/filepath" "time" "github.com/gorilla/mux" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/msghub" + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/message" + "github.com/jhillyerd/inbucket/pkg/msghub" + "github.com/rs/zerolog/log" ) // Handler is a function type that handles an HTTP request in Inbucket type Handler func(http.ResponseWriter, *http.Request, *Context) error -var ( - // DataStore is where all the mailboxes and messages live - DataStore datastore.DataStore +const ( + staticDir = "static" + templateDir = "templates" +) +var ( // 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 // incoming requests to the correct handler function Router = mux.NewRouter() - webConfig config.WebConfig + rootConfig *config.Root server *http.Server listener net.Listener sessionStore sessions.Store @@ -49,51 +52,55 @@ func init() { // Initialize sets up things for unit tests or the Start() method func Initialize( - cfg config.WebConfig, + conf *config.Root, shutdownChan chan bool, - ds datastore.DataStore, + mm message.Manager, mh *msghub.Hub) { - webConfig = cfg + rootConfig = conf globalShutdown = shutdownChan // NewContext() will use this DataStore for the web handlers - DataStore = ds msgHub = mh + manager = mm // Content Paths - log.Infof("HTTP templates mapped to %q", cfg.TemplateDir) - log.Infof("HTTP static content mapped to %q", cfg.PublicDir) + staticPath := filepath.Join(conf.Web.UIDir, staticDir) + 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/", - http.FileServer(http.Dir(cfg.PublicDir)))) + http.FileServer(http.Dir(staticPath)))) http.Handle("/", Router) // Session cookie setup - if cfg.CookieAuthKey == "" { - log.Infof("HTTP generating random cookie.auth.key") + if conf.Web.CookieAuthKey == "" { + log.Info().Str("module", "web").Str("phase", "startup"). + Msg("Generating random cookie.auth.key") sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64)) } else { - log.Tracef("HTTP using configured cookie.auth.key") - sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey)) + log.Info().Str("module", "web").Str("phase", "startup"). + Msg("Using configured cookie.auth.key") + sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey)) } } // Start begins listening for HTTP requests func Start(ctx context.Context) { - addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port) server = &http.Server{ - Addr: addr, + Addr: rootConfig.Web.Addr, Handler: nil, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, } // 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 - listener, err = net.Listen("tcp", addr) + listener, err = net.Listen("tcp", server.Addr) 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() return } @@ -104,12 +111,14 @@ func Start(ctx context.Context) { // Wait for shutdown select { 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 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(): // Nop default: - log.Errorf("HTTP server failed: %v", err) + log.Error().Str("module", "web").Str("phase", "startup").Err(err). + Msg("HTTP server failed") emergencyShutdown() return } @@ -133,17 +143,19 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Create the context ctx, err := NewContext(req) 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) return } defer ctx.Close() // 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) 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) return } diff --git a/httpd/template.go b/pkg/server/web/template.go similarity index 70% rename from httpd/template.go rename to pkg/server/web/template.go index 3aa81ce..f9f8da2 100644 --- a/httpd/template.go +++ b/pkg/server/web/template.go @@ -1,14 +1,13 @@ -package httpd +package web import ( "html/template" "net/http" "path" "path/filepath" - "strings" "sync" - "github.com/jhillyerd/inbucket/log" + "github.com/rs/zerolog/log" ) var cachedMutex sync.Mutex @@ -20,7 +19,8 @@ var cachedPartials = map[string]*template.Template{} func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error { t, err := ParseTemplate(name, false) 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 } 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 { t, err := ParseTemplate(name, true) 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 } w.Header().Set("Expires", "-1") @@ -49,9 +50,8 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) { return t, nil } - tempPath := strings.Replace(name, "/", string(filepath.Separator), -1) - tempFile := filepath.Join(webConfig.TemplateDir, tempPath) - log.Tracef("Parsing template %v", tempFile) + tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name)) + log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template") var err error var t *template.Template @@ -62,19 +62,20 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) { t, err = t.ParseFiles(tempFile) } else { 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 { return nil, err } // Allows us to disable caching for theme development - if webConfig.TemplateCache { + if rootConfig.Web.TemplateCache { if partial { - log.Tracef("Caching partial %v", name) + log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial") cachedTemplates[name] = t } else { - log.Tracef("Caching template %v", name) + log.Debug().Str("module", "web").Str("path", name).Msg("Caching template") cachedTemplates[name] = t } } diff --git a/pkg/storage/file/fmessage.go b/pkg/storage/file/fmessage.go new file mode 100644 index 0000000..a7ed1e2 --- /dev/null +++ b/pkg/storage/file/fmessage.go @@ -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 +} diff --git a/pkg/storage/file/fstore.go b/pkg/storage/file/fstore.go new file mode 100644 index 0000000..a52bc36 --- /dev/null +++ b/pkg/storage/file/fstore.go @@ -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) +} diff --git a/pkg/storage/file/fstore_test.go b/pkg/storage/file/fstore_test.go new file mode 100644 index 0000000..7023706 --- /dev/null +++ b/pkg/storage/file/fstore_test.go @@ -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 +} diff --git a/pkg/storage/file/mbox.go b/pkg/storage/file/mbox.go new file mode 100644 index 0000000..8c85145 --- /dev/null +++ b/pkg/storage/file/mbox.go @@ -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 +} diff --git a/pkg/storage/lock.go b/pkg/storage/lock.go new file mode 100644 index 0000000..8612e80 --- /dev/null +++ b/pkg/storage/lock.go @@ -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] +} diff --git a/datastore/lock_test.go b/pkg/storage/lock_test.go similarity index 92% rename from datastore/lock_test.go rename to pkg/storage/lock_test.go index 2da536a..ab87bba 100644 --- a/datastore/lock_test.go +++ b/pkg/storage/lock_test.go @@ -1,13 +1,13 @@ -package datastore_test +package storage_test import ( "testing" - "github.com/jhillyerd/inbucket/datastore" + "github.com/jhillyerd/inbucket/pkg/storage" ) func TestHashLock(t *testing.T) { - hl := &datastore.HashLock{} + hl := &storage.HashLock{} // Invalid hashes testCases := []struct { diff --git a/pkg/storage/mem/maxsize.go b/pkg/storage/mem/maxsize.go new file mode 100644 index 0000000..3b11850 --- /dev/null +++ b/pkg/storage/mem/maxsize.go @@ -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 + } +} diff --git a/pkg/storage/mem/message.go b/pkg/storage/mem/message.go new file mode 100644 index 0000000..02ae503 --- /dev/null +++ b/pkg/storage/mem/message.go @@ -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 } diff --git a/pkg/storage/mem/store.go b/pkg/storage/mem/store.go new file mode 100644 index 0000000..47ef265 --- /dev/null +++ b/pkg/storage/mem/store.go @@ -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) +} diff --git a/pkg/storage/mem/store_test.go b/pkg/storage/mem/store_test.go new file mode 100644 index 0000000..c48bca8 --- /dev/null +++ b/pkg/storage/mem/store_test.go @@ -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) + } +} diff --git a/datastore/retention.go b/pkg/storage/retention.go similarity index 58% rename from datastore/retention.go rename to pkg/storage/retention.go index 7d52c27..166da6b 100644 --- a/datastore/retention.go +++ b/pkg/storage/retention.go @@ -1,4 +1,4 @@ -package datastore +package storage import ( "container/list" @@ -6,8 +6,9 @@ import ( "sync" "time" - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/log" + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/metric" + "github.com/rs/zerolog/log" ) var ( @@ -18,14 +19,17 @@ var ( expRetentionDeletesTotal = new(expvar.Int) expRetentionPeriod = new(expvar.Int) expRetainedCurrent = new(expvar.Int) + expRetainedSize = new(expvar.Int) // History of certain stats retentionDeletesHist = list.New() retainedHist = list.New() + sizeHist = list.New() // History rendered as comma delimited string expRetentionDeletesHist = new(expvar.String) expRetainedHist = new(expvar.String) + expSizeHist = new(expvar.String) ) func init() { @@ -36,10 +40,13 @@ func init() { rm.Set("Period", expRetentionPeriod) rm.Set("RetainedHist", expRetainedHist) rm.Set("RetainedCurrent", expRetainedCurrent) + rm.Set("RetainedSize", expRetainedSize) + rm.Set("SizeHist", expSizeHist) - log.AddTickerFunc(func() { - expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal)) - expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent)) + metric.AddTickerFunc(func() { + expRetentionDeletesHist.Set(metric.Push(retentionDeletesHist, expRetentionDeletesTotal)) + expRetainedHist.Set(metric.Push(retainedHist, expRetainedCurrent)) + expSizeHist.Set(metric.Push(sizeHist, expRetainedSize)) }) } @@ -47,40 +54,44 @@ func init() { type RetentionScanner struct { globalShutdown chan bool // Closes when Inbucket needs to shut down retentionShutdown chan bool // Closed after the scanner has shut down - ds DataStore + ds Store retentionPeriod time.Duration retentionSleep time.Duration } -// NewRetentionScanner launches a go-routine that scans for expired -// messages, following the configured interval -func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner { - cfg := config.GetDataStoreConfig() +// NewRetentionScanner configures a new RententionScanner. +func NewRetentionScanner( + cfg config.Storage, + ds Store, + shutdownChannel chan bool, +) *RetentionScanner { rs := &RetentionScanner{ globalShutdown: shutdownChannel, retentionShutdown: make(chan bool), ds: ds, - retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute, - retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond, + retentionPeriod: cfg.RetentionPeriod, + retentionSleep: cfg.RetentionSleep, } // expRetentionPeriod is displayed on the status page - expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60)) + expRetentionPeriod.Set(int64(cfg.RetentionPeriod / time.Second)) return rs } // Start up the retention scanner if retention period > 0 func (rs *RetentionScanner) Start() { if rs.retentionPeriod <= 0 { - log.Infof("Retention scanner disabled") + log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled") close(rs.retentionShutdown) 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() } // run loops to kick off the scanner on the correct schedule func (rs *RetentionScanner) run() { + slog := log.With().Str("module", "storage").Logger() start := time.Now() retentionLoop: for { @@ -88,7 +99,7 @@ retentionLoop: since := time.Since(start) if since < time.Minute { dur := time.Minute - since - log.Tracef("Retention scanner sleeping for %v", dur) + slog.Debug().Msgf("Retention scanner sleeping for %v", dur) select { case <-rs.globalShutdown: break retentionLoop @@ -97,8 +108,8 @@ retentionLoop: } // Kickoff scan start = time.Now() - if err := rs.doScan(); err != nil { - log.Errorf("Error during retention scan: %v", err) + if err := rs.DoScan(); err != nil { + slog.Error().Err(err).Msg("Error during retention scan") } // Check for global shutdown select { @@ -107,56 +118,54 @@ retentionLoop: default: } } - log.Tracef("Retention scanner shut down") + slog.Debug().Str("phase", "shutdown").Msg("Retention scanner shut down") close(rs.retentionShutdown) } -// doScan does a single pass of all mailboxes looking for messages that can be purged -func (rs *RetentionScanner) doScan() error { - log.Tracef("Starting retention scan") +// DoScan does a single pass of all mailboxes looking for messages that can be purged. +func (rs *RetentionScanner) DoScan() error { + slog := log.With().Str("module", "storage").Logger() + slog.Debug().Msg("Starting retention scan") cutoff := time.Now().Add(-1 * rs.retentionPeriod) - mboxes, err := rs.ds.AllMailboxes() - if err != nil { - return err - } retained := 0 - // Loop over all mailboxes - for _, mb := range mboxes { - messages, err := mb.GetMessages() - if err != nil { - return err - } - // Loop over all messages in mailbox + storeSize := int64(0) + // Loop over all mailboxes. + err := rs.ds.VisitMailboxes(func(messages []Message) bool { for _, msg := range messages { if msg.Date().Before(cutoff) { - log.Tracef("Purging expired message %v", msg.ID()) - err = msg.Delete() - if err != nil { - // Log but don't abort - log.Errorf("Failed to purge message %v: %v", msg.ID(), err) + slog.Debug().Str("mailbox", msg.Mailbox()). + Msgf("Purging expired message %v", msg.ID()) + if err := rs.ds.RemoveMessage(msg.Mailbox(), msg.ID()); err != nil { + slog.Error().Str("mailbox", msg.Mailbox()).Err(err). + Msgf("Failed to purge message %v", msg.ID()) } else { expRetentionDeletesTotal.Add(1) } } else { retained++ + storeSize += msg.Size() } } - // Sleep after completing a mailbox select { case <-rs.globalShutdown: - log.Tracef("Retention scan aborted due to shutdown") - return nil + slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown") + return false case <-time.After(rs.retentionSleep): // Reduce disk thrashing } + return true + }) + if err != nil { + return err } // Update metrics setRetentionScanCompleted(time.Now()) expRetainedCurrent.Set(int64(retained)) + expRetainedSize.Set(storeSize) 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() { if rs.retentionShutdown != nil { <-rs.retentionShutdown diff --git a/pkg/storage/retention_test.go b/pkg/storage/retention_test.go new file mode 100644 index 0000000..e918cc1 --- /dev/null +++ b/pkg/storage/retention_test.go @@ -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), + }, + } +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go new file mode 100644 index 0000000..7cd40b3 --- /dev/null +++ b/pkg/storage/storage.go @@ -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) +} diff --git a/pkg/stringutil/utils.go b/pkg/stringutil/utils.go new file mode 100644 index 0000000..afde694 --- /dev/null +++ b/pkg/stringutil/utils.go @@ -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) + } +} diff --git a/pkg/stringutil/utils_test.go b/pkg/stringutil/utils_test.go new file mode 100644 index 0000000..8c0b7bd --- /dev/null +++ b/pkg/stringutil/utils_test.go @@ -0,0 +1,33 @@ +package stringutil_test + +import ( + "net/mail" + "testing" + + "github.com/jhillyerd/inbucket/pkg/stringutil" +) + +func TestHashMailboxName(t *testing.T) { + want := "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e" + got := stringutil.HashMailboxName("mail") + if got != want { + t.Errorf("Got %q, want %q", got, want) + } +} + +func TestStringAddressList(t *testing.T) { + input := []*mail.Address{ + {Name: "Fred B. Fish", Address: "fred@fish.org"}, + {Name: "User", Address: "user@domain.org"}, + } + want := []string{`"Fred B. Fish" `, `"User" `} + output := stringutil.StringAddressList(input) + if len(output) != len(want) { + t.Fatalf("Got %v strings, want: %v", len(output), len(want)) + } + for i, got := range output { + if got != want[i] { + t.Errorf("Got %q, want: %q", got, want[i]) + } + } +} diff --git a/pkg/test/manager.go b/pkg/test/manager.go new file mode 100644 index 0000000..fa77ef5 --- /dev/null +++ b/pkg/test/manager.go @@ -0,0 +1,77 @@ +package test + +import ( + "errors" + + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/message" + "github.com/jhillyerd/inbucket/pkg/policy" + "github.com/jhillyerd/inbucket/pkg/storage" +) + +// ManagerStub is a test stub for message.Manager +type ManagerStub struct { + message.Manager + mailboxes map[string][]*message.Message +} + +// NewManager creates a new ManagerStub. +func NewManager() *ManagerStub { + return &ManagerStub{ + mailboxes: make(map[string][]*message.Message), + } +} + +// AddMessage adds a message to the specified mailbox. +func (m *ManagerStub) AddMessage(mailbox string, msg *message.Message) { + messages := m.mailboxes[mailbox] + m.mailboxes[mailbox] = append(messages, msg) +} + +// GetMessage gets a message by ID from the specified mailbox. +func (m *ManagerStub) GetMessage(mailbox, id string) (*message.Message, error) { + if mailbox == "messageerr" { + return nil, errors.New("internal error") + } + for _, msg := range m.mailboxes[mailbox] { + if msg.ID == id { + return msg, nil + } + } + return nil, storage.ErrNotExist +} + +// GetMetadata gets all the metadata for the specified mailbox. +func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) { + if mailbox == "messageserr" { + return nil, errors.New("internal error") + } + messages := m.mailboxes[mailbox] + metas := make([]*message.Metadata, len(messages)) + for i, msg := range messages { + metas[i] = &msg.Metadata + } + return metas, nil +} + +// MailboxForAddress invokes policy.ParseMailboxName. +func (m *ManagerStub) MailboxForAddress(address string) (string, error) { + addrPolicy := &policy.Addressing{Config: &config.Root{ + MailboxNaming: config.FullNaming, + }} + return addrPolicy.ExtractMailbox(address) +} + +// MarkSeen marks a message as having been read. +func (m *ManagerStub) MarkSeen(mailbox, id string) error { + if mailbox == "messageerr" { + return errors.New("internal error") + } + for _, msg := range m.mailboxes[mailbox] { + if msg.ID == id { + msg.Metadata.Seen = true + return nil + } + } + return storage.ErrNotExist +} diff --git a/pkg/test/storage.go b/pkg/test/storage.go new file mode 100644 index 0000000..b52445d --- /dev/null +++ b/pkg/test/storage.go @@ -0,0 +1,88 @@ +package test + +import ( + "errors" + + "github.com/jhillyerd/inbucket/pkg/storage" +) + +// StoreStub stubs storage.Store for testing. +type StoreStub struct { + storage.Store + mailboxes map[string][]storage.Message + deleted map[storage.Message]struct{} +} + +// NewStore creates a new StoreStub. +func NewStore() *StoreStub { + return &StoreStub{ + mailboxes: make(map[string][]storage.Message), + deleted: make(map[storage.Message]struct{}), + } +} + +// AddMessage adds a message to the specified mailbox. +func (s *StoreStub) AddMessage(m storage.Message) (id string, err error) { + mb := m.Mailbox() + msgs := s.mailboxes[mb] + s.mailboxes[mb] = append(msgs, m) + return m.ID(), nil +} + +// GetMessage gets a message by ID from the specified mailbox. +func (s *StoreStub) GetMessage(mailbox, id string) (storage.Message, error) { + if mailbox == "messageerr" { + return nil, errors.New("internal error") + } + for _, m := range s.mailboxes[mailbox] { + if m.ID() == id { + return m, nil + } + } + return nil, storage.ErrNotExist +} + +// GetMessages gets all the messages for the specified mailbox. +func (s *StoreStub) GetMessages(mailbox string) ([]storage.Message, error) { + if mailbox == "messageserr" { + return nil, errors.New("internal error") + } + return s.mailboxes[mailbox], nil +} + +// RemoveMessage deletes a message by ID from the specified mailbox. +func (s *StoreStub) RemoveMessage(mailbox, id string) error { + mb, ok := s.mailboxes[mailbox] + if ok { + var msg storage.Message + for i, m := range mb { + if m.ID() == id { + msg = m + s.mailboxes[mailbox] = append(mb[:i], mb[i+1:]...) + break + } + } + if msg != nil { + s.deleted[msg] = struct{}{} + return nil + } + } + return storage.ErrNotExist +} + +// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it +// continues to return true. +func (s *StoreStub) VisitMailboxes(f func([]storage.Message) (cont bool)) error { + for _, v := range s.mailboxes { + if !f(v) { + return nil + } + } + return nil +} + +// MessageDeleted returns true if the specified message was deleted +func (s *StoreStub) MessageDeleted(m storage.Message) bool { + _, ok := s.deleted[m] + return ok +} diff --git a/pkg/test/storage_suite.go b/pkg/test/storage_suite.go new file mode 100644 index 0000000..26a82a4 --- /dev/null +++ b/pkg/test/storage_suite.go @@ -0,0 +1,420 @@ +package test + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/mail" + "strings" + "testing" + "time" + + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/message" + "github.com/jhillyerd/inbucket/pkg/storage" +) + +// StoreFactory returns a new store for the test suite. +type StoreFactory func(config.Storage) (store storage.Store, destroy func(), err error) + +// StoreSuite runs a set of general tests on the provided Store. +func StoreSuite(t *testing.T, factory StoreFactory) { + testCases := []struct { + name string + test func(*testing.T, storage.Store) + conf config.Storage + }{ + {"metadata", testMetadata, config.Storage{}}, + {"content", testContent, config.Storage{}}, + {"delivery order", testDeliveryOrder, config.Storage{}}, + {"naming", testNaming, config.Storage{}}, + {"size", testSize, config.Storage{}}, + {"seen", testSeen, config.Storage{}}, + {"delete", testDelete, config.Storage{}}, + {"purge", testPurge, config.Storage{}}, + {"cap=10", testMsgCap, config.Storage{MailboxMsgCap: 10}}, + {"cap=0", testNoMsgCap, config.Storage{MailboxMsgCap: 0}}, + {"visit mailboxes", testVisitMailboxes, config.Storage{}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + store, destroy, err := factory(tc.conf) + if err != nil { + t.Fatal(err) + } + tc.test(t, store) + destroy() + }) + } +} + +// testMetadata verifies message metadata is stored and retrieved correctly. +func testMetadata(t *testing.T, store storage.Store) { + mailbox := "testmailbox" + from := &mail.Address{Name: "From Person", Address: "from@person.com"} + to := []*mail.Address{ + {Name: "One Person", Address: "one@a.person.com"}, + {Name: "Two Person", Address: "two@b.person.com"}, + } + date := time.Now() + subject := "fantastic test subject line" + content := "doesn't matter" + delivery := &message.Delivery{ + Meta: message.Metadata{ + // ID and Size will be determined by the Store. + Mailbox: mailbox, + From: from, + To: to, + Date: date, + Subject: subject, + Seen: false, + }, + Reader: strings.NewReader(content), + } + id, err := store.AddMessage(delivery) + if err != nil { + t.Fatal(err) + } + if id == "" { + t.Fatal("Expected AddMessage() to return non-empty ID string") + } + // Retrieve and validate the message. + sm, err := store.GetMessage(mailbox, id) + if err != nil { + t.Fatal(err) + } + if sm.Mailbox() != mailbox { + t.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox) + } + if sm.ID() != id { + t.Errorf("got id %q, want: %q", sm.ID(), id) + } + if *sm.From() != *from { + t.Errorf("got from %v, want: %v", sm.From(), from) + } + if len(sm.To()) != len(to) { + t.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to)) + } else { + for i, got := range sm.To() { + if *to[i] != *got { + t.Errorf("got to[%v] %v, want: %v", i, got, to[i]) + } + } + } + if !sm.Date().Equal(date) { + t.Errorf("got date %v, want: %v", sm.Date(), date) + } + if sm.Subject() != subject { + t.Errorf("got subject %q, want: %q", sm.Subject(), subject) + } + if sm.Size() != int64(len(content)) { + t.Errorf("got size %v, want: %v", sm.Size(), len(content)) + } + if sm.Seen() { + t.Errorf("got seen %v, want: false", sm.Seen()) + } +} + +// testContent generates some binary content and makes sure it is correctly retrieved. +func testContent(t *testing.T, store storage.Store) { + content := make([]byte, 5000) + for i := 0; i < len(content); i++ { + content[i] = byte(i % 256) + } + mailbox := "testmailbox" + from := &mail.Address{Name: "From Person", Address: "from@person.com"} + to := []*mail.Address{ + {Name: "One Person", Address: "one@a.person.com"}, + } + date := time.Now() + subject := "fantastic test subject line" + delivery := &message.Delivery{ + Meta: message.Metadata{ + // ID and Size will be determined by the Store. + Mailbox: mailbox, + From: from, + To: to, + Date: date, + Subject: subject, + }, + Reader: bytes.NewReader(content), + } + id, err := store.AddMessage(delivery) + if err != nil { + t.Fatal(err) + } + // Get and check. + m, err := store.GetMessage(mailbox, id) + if err != nil { + t.Fatal(err) + } + r, err := m.Source() + if err != nil { + t.Fatal(err) + } + got, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if len(got) != len(content) { + t.Errorf("Got len(content) == %v, want: %v", len(got), len(content)) + } + errors := 0 + for i, b := range got { + if b != content[i] { + t.Errorf("Got content[%v] == %v, want: %v", i, b, content[i]) + errors++ + } + if errors > 5 { + t.Fatalf("Too many content errors, aborting test.") + break + } + } +} + +// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents +// with a new GetMessages call each cycle. +func testDeliveryOrder(t *testing.T, store storage.Store) { + mailbox := "fred" + subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} + for i, subj := range subjects { + // Check mailbox count. + GetAndCountMessages(t, store, mailbox, i) + DeliverToStore(t, store, mailbox, subj, time.Now()) + } + // Confirm delivery order. + msgs := GetAndCountMessages(t, store, mailbox, 5) + for i, want := range subjects { + got := msgs[i].Subject() + if got != want { + t.Errorf("Got subject %q, want %q", got, want) + } + } +} + +// testNaming ensures the store does not enforce local part mailbox naming. +func testNaming(t *testing.T, store storage.Store) { + DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now()) + GetAndCountMessages(t, store, "fred", 0) + GetAndCountMessages(t, store, "fred@fish.net", 1) +} + +// testSize verifies message contnet size metadata values. +func testSize(t *testing.T, store storage.Store) { + mailbox := "fred" + subjects := []string{"a", "br", "much longer than the others"} + sentIds := make([]string, len(subjects)) + sentSizes := make([]int64, len(subjects)) + for i, subj := range subjects { + id, size := DeliverToStore(t, store, mailbox, subj, time.Now()) + sentIds[i] = id + sentSizes[i] = size + } + for i, id := range sentIds { + msg, err := store.GetMessage(mailbox, id) + if err != nil { + t.Fatal(err) + } + want := sentSizes[i] + got := msg.Size() + if got != want { + t.Errorf("Got size %v, want: %v", got, want) + } + } +} + +// testSeen verifies a message can be marked as seen. +func testSeen(t *testing.T, store storage.Store) { + mailbox := "lisa" + id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now()) + id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now()) + // Confirm unseen. + msg, err := store.GetMessage(mailbox, id1) + if err != nil { + t.Fatal(err) + } + if msg.Seen() { + t.Errorf("got seen %v, want: false", msg.Seen()) + } + // Mark id1 seen. + err = store.MarkSeen(mailbox, id1) + if err != nil { + t.Fatal(err) + } + // Verify id1 seen. + msg, err = store.GetMessage(mailbox, id1) + if err != nil { + t.Fatal(err) + } + if !msg.Seen() { + t.Errorf("id1 got seen %v, want: true", msg.Seen()) + } + // Verify id2 still unseen. + msg, err = store.GetMessage(mailbox, id2) + if err != nil { + t.Fatal(err) + } + if msg.Seen() { + t.Errorf("id2 got seen %v, want: false", msg.Seen()) + } +} + +// testDelete creates and deletes some messages. +func testDelete(t *testing.T, store storage.Store) { + mailbox := "fred" + subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} + for _, subj := range subjects { + DeliverToStore(t, store, mailbox, subj, time.Now()) + } + msgs := GetAndCountMessages(t, store, mailbox, len(subjects)) + // Delete a couple messages. + err := store.RemoveMessage(mailbox, msgs[1].ID()) + if err != nil { + t.Fatal(err) + } + err = store.RemoveMessage(mailbox, msgs[3].ID()) + if err != nil { + t.Fatal(err) + } + // Confirm deletion. + subjects = []string{"alpha", "charlie", "echo"} + msgs = GetAndCountMessages(t, store, mailbox, len(subjects)) + for i, want := range subjects { + got := msgs[i].Subject() + if got != want { + t.Errorf("Got subject %q, want %q", got, want) + } + } + // Try appending one more. + DeliverToStore(t, store, mailbox, "foxtrot", time.Now()) + subjects = []string{"alpha", "charlie", "echo", "foxtrot"} + msgs = GetAndCountMessages(t, store, mailbox, len(subjects)) + for i, want := range subjects { + got := msgs[i].Subject() + if got != want { + t.Errorf("Got subject %q, want %q", got, want) + } + } +} + +// testPurge makes sure mailboxes can be purged. +func testPurge(t *testing.T, store storage.Store) { + mailbox := "fred" + subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"} + for _, subj := range subjects { + DeliverToStore(t, store, mailbox, subj, time.Now()) + } + GetAndCountMessages(t, store, mailbox, len(subjects)) + // Purge and verify. + err := store.PurgeMessages(mailbox) + if err != nil { + t.Fatal(err) + } + GetAndCountMessages(t, store, mailbox, 0) +} + +// testMsgCap verifies the message cap is enforced. +func testMsgCap(t *testing.T, store storage.Store) { + mbCap := 10 + mailbox := "captain" + for i := 0; i < 20; i++ { + subj := fmt.Sprintf("subject %v", i) + DeliverToStore(t, store, mailbox, subj, time.Now()) + msgs, err := store.GetMessages(mailbox) + if err != nil { + t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err) + } + if len(msgs) > mbCap { + t.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap) + break + } + // 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("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj) + } + } +} + +// testNoMsgCap verfies a cap of 0 is not enforced. +func testNoMsgCap(t *testing.T, store storage.Store) { + mailbox := "captain" + for i := 0; i < 20; i++ { + subj := fmt.Sprintf("subject %v", i) + DeliverToStore(t, store, mailbox, subj, time.Now()) + GetAndCountMessages(t, store, mailbox, i+1) + } +} + +// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of +// them. +func testVisitMailboxes(t *testing.T, ds storage.Store) { + boxes := []string{"abby", "bill", "christa", "donald", "evelyn"} + for _, name := range boxes { + DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour)) + DeliverToStore(t, ds, name, "New Message", time.Now()) + } + seen := 0 + err := ds.VisitMailboxes(func(messages []storage.Message) bool { + seen++ + count := len(messages) + if count != 2 { + t.Errorf("got: %v messages, want: 2", count) + } + return true + }) + if err != nil { + t.Error(err) + } + if seen != 5 { + t.Errorf("saw %v messages in total, want: 5", seen) + } +} + +// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the +// generated message. +func DeliverToStore( + t *testing.T, + store storage.Store, + mailbox string, + subject string, + date time.Time, +) (string, int64) { + t.Helper() + meta := message.Metadata{ + Mailbox: mailbox, + To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}}, + From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"}, + Subject: subject, + Date: date, + } + testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n", + meta.To[0].Address, meta.From.Address, subject) + delivery := &message.Delivery{ + Meta: meta, + Reader: ioutil.NopCloser(strings.NewReader(testMsg)), + } + id, err := store.AddMessage(delivery) + if err != nil { + t.Fatal(err) + } + return id, int64(len(testMsg)) +} + +// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it +// also checks return error. +func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message { + t.Helper() + msgs, err := s.GetMessages(mailbox) + if err != nil { + t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err) + } + if len(msgs) != count { + t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count) + } + return msgs +} diff --git a/webui/mailbox_controller.go b/pkg/webui/mailbox_controller.go similarity index 52% rename from webui/mailbox_controller.go rename to pkg/webui/mailbox_controller.go index 7f2d6e8..71dbd42 100644 --- a/webui/mailbox_controller.go +++ b/pkg/webui/mailbox_controller.go @@ -7,29 +7,28 @@ import ( "net/http" "strconv" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/httpd" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/sanitize" - "github.com/jhillyerd/inbucket/stringutil" + "github.com/jhillyerd/inbucket/pkg/server/web" + "github.com/jhillyerd/inbucket/pkg/storage" + "github.com/jhillyerd/inbucket/pkg/webui/sanitize" + "github.com/rs/zerolog/log" ) // MailboxIndex renders the index page for a particular mailbox -func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { // Form values must be validated manually name := req.FormValue("name") selected := req.FormValue("id") if len(name) == 0 { ctx.Session.AddFlash("Account name is required", "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } - name, err = stringutil.ParseMailboxName(name) + name, err = ctx.Manager.MailboxForAddress(name) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } // Remember this mailbox was visited @@ -40,7 +39,7 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) return err } // Render template - return httpd.RenderTemplate("mailbox/index.html", w, map[string]interface{}{ + return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{ "ctx": ctx, "errorFlash": errorFlash, "name": name, @@ -48,43 +47,52 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) }) } -// MailboxLink handles pretty links to a particular message. Renders a redirect -func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - id := ctx.Vars["id"] - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) +// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect +func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } // Build redirect - uri := fmt.Sprintf("%s?name=%s&id=%s", httpd.Reverse("MailboxIndex"), name, id) + uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name) + http.Redirect(w, req, uri, http.StatusSeeOther) + return nil +} + +// MailboxLink handles pretty links to a particular message. Renders a redirect +func MailboxLink(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 { + ctx.Session.AddFlash(err.Error(), "errors") + _ = ctx.Session.Save(req, w) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) + return nil + } + // Build redirect + uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id) http.Redirect(w, req, uri, http.StatusSeeOther) return nil } // MailboxList renders a list of messages in a mailbox. Renders a partial -func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxList(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - messages, err := mb.GetMessages() + 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) } - log.Tracef("Got %v messsages", len(messages)) // Render partial template - return httpd.RenderPartial("mailbox/_list.html", w, map[string]interface{}{ + return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{ "ctx": ctx, "name": name, "messages": messages, @@ -92,20 +100,15 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( } // MailboxShow renders a particular message from a mailbox. Renders an HTML partial -func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxShow(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - msg, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { + msg, err := ctx.Manager.GetMessage(name, id) + if err == storage.ErrNotExist { http.NotFound(w, req) return nil } @@ -113,114 +116,91 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( // This doesn't indicate empty, likely an IO error return fmt.Errorf("GetMessage(%q) failed: %v", id, err) } - mime, err := msg.ReadBody() - if err != nil { - return fmt.Errorf("ReadBody(%q) failed: %v", id, err) - } - body := template.HTML(httpd.TextToHTML(mime.Text)) - htmlAvailable := mime.HTML != "" + body := template.HTML(web.TextToHTML(msg.Text())) + htmlAvailable := msg.HTML() != "" var htmlBody template.HTML if htmlAvailable { - if str, err := sanitize.HTML(mime.HTML); err == nil { + if str, err := sanitize.HTML(msg.HTML()); err == nil { htmlBody = template.HTML(str) } else { - log.Warnf("HTML sanitizer failed: %s", err) + // Soft failure, render empty tab. + log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err). + Msg("HTML sanitizer failed") } } // Render partial template - return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ + return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ "ctx": ctx, "name": name, "message": msg, "body": body, "htmlAvailable": htmlAvailable, "htmlBody": htmlBody, - "mimeErrors": mime.Errors, - "attachments": mime.Attachments, + "mimeErrors": msg.MIMEErrors(), + "attachments": msg.Attachments(), }) } // MailboxHTML displays the HTML content of a message. Renders a partial -func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxHTML(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { + msg, err := ctx.Manager.GetMessage(name, id) + if err == storage.ErrNotExist { http.NotFound(w, req) return nil } if err != nil { - // This doesn't indicate missing, likely an IO error + // This doesn't indicate empty, likely an IO error return fmt.Errorf("GetMessage(%q) failed: %v", id, err) } - mime, err := message.ReadBody() - if err != nil { - return fmt.Errorf("ReadBody(%q) failed: %v", id, err) - } // Render partial template w.Header().Set("Content-Type", "text/html; charset=UTF-8") - return httpd.RenderPartial("mailbox/_html.html", w, map[string]interface{}{ + return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{ "ctx": ctx, "name": name, - "message": message, - // TODO It is not really safe to render, need to sanitize, issue #5 - "body": template.HTML(mime.HTML), + "message": msg, + "body": template.HTML(msg.HTML()), }) } // MailboxSource displays the raw source of a message, including headers. Renders text/plain -func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxSource(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { + r, err := ctx.Manager.SourceReader(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("GetMessage(%q) failed: %v", id, err) - } - raw, err := message.ReadRaw() - if err != nil { - return fmt.Errorf("ReadRaw(%q) failed: %v", id, err) + return fmt.Errorf("SourceReader(%q) failed: %v", id, err) } // Output message source w.Header().Set("Content-Type", "text/plain") - if _, err := io.WriteString(w, *raw); err != nil { - return err - } - return nil + _, err = io.Copy(w, r) + return err } // MailboxDownloadAttach sends the attachment to the client; disposition: // attachment, type: application/octet-stream -func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxDownloadAttach(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } numStr := ctx.Vars["num"] @@ -228,51 +208,39 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd. if err != nil { ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { + msg, err := ctx.Manager.GetMessage(name, id) + if err == storage.ErrNotExist { http.NotFound(w, req) return nil } if err != nil { - // This doesn't indicate missing, likely an IO error + // This doesn't indicate empty, likely an IO error return fmt.Errorf("GetMessage(%q) failed: %v", id, err) } - body, err := message.ReadBody() - if err != nil { - return err - } - if int(num) >= len(body.Attachments) { + if int(num) >= len(msg.Attachments()) { ctx.Session.AddFlash("Attachment number too high", "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } - part := body.Attachments[num] // Output attachment w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment") - if _, err := io.Copy(w, part); err != nil { - return err - } - return nil + _, err = w.Write(msg.Attachments()[num].Content) + return err } // MailboxViewAttach sends the attachment to the client for online viewing -func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { +func MailboxViewAttach(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 := stringutil.ParseMailboxName(ctx.Vars["name"]) + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } id := ctx.Vars["id"] @@ -281,38 +249,27 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont if err != nil { ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { + msg, err := ctx.Manager.GetMessage(name, id) + if err == storage.ErrNotExist { http.NotFound(w, req) return nil } if err != nil { - // This doesn't indicate missing, likely an IO error + // This doesn't indicate empty, likely an IO error return fmt.Errorf("GetMessage(%q) failed: %v", id, err) } - body, err := message.ReadBody() - if err != nil { - return err - } - if int(num) >= len(body.Attachments) { + if int(num) >= len(msg.Attachments()) { ctx.Session.AddFlash("Attachment number too high", "errors") _ = ctx.Session.Save(req, w) - http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) return nil } - part := body.Attachments[num] // Output attachment + part := msg.Attachments()[num] w.Header().Set("Content-Type", part.ContentType) - if _, err := io.Copy(w, part); err != nil { - return err - } - return nil + _, err = w.Write(part.Content) + return err } diff --git a/webui/recent.go b/pkg/webui/recent.go similarity index 83% rename from webui/recent.go rename to pkg/webui/recent.go index 14118ac..97168d3 100644 --- a/webui/recent.go +++ b/pkg/webui/recent.go @@ -1,7 +1,7 @@ package webui import ( - "github.com/jhillyerd/inbucket/httpd" + "github.com/jhillyerd/inbucket/pkg/server/web" ) const ( @@ -12,7 +12,7 @@ const ( ) // RememberMailbox manages the list of recently accessed mailboxes stored in the session -func RememberMailbox(ctx *httpd.Context, mailbox string) { +func RememberMailbox(ctx *web.Context, mailbox string) { recent := RecentMailboxes(ctx) newRecent := make([]string, 1, maxRemembered) newRecent[0] = mailbox @@ -28,7 +28,7 @@ func RememberMailbox(ctx *httpd.Context, mailbox string) { } // RecentMailboxes returns a slice of the most recently accessed mailboxes -func RecentMailboxes(ctx *httpd.Context) []string { +func RecentMailboxes(ctx *web.Context) []string { val := ctx.Session.Values[mailboxKey] recent, _ := val.([]string) return recent diff --git a/pkg/webui/root_controller.go b/pkg/webui/root_controller.go new file mode 100644 index 0000000..352d51b --- /dev/null +++ b/pkg/webui/root_controller.go @@ -0,0 +1,99 @@ +package webui + +import ( + "fmt" + "html/template" + "io/ioutil" + "net/http" + + "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/server/web" +) + +// RootIndex serves the Inbucket landing page +func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile) + if err != nil { + return fmt.Errorf("Failed to load greeting: %v", err) + } + // Get flash messages, save session + errorFlash := ctx.Session.Flashes("errors") + if err = ctx.Session.Save(req, w); err != nil { + return err + } + // Render template + return web.RenderTemplate("root/index.html", w, map[string]interface{}{ + "ctx": ctx, + "errorFlash": errorFlash, + "greeting": template.HTML(string(greeting)), + }) +} + +// RootMonitor serves the Inbucket monitor page +func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + if !ctx.RootConfig.Web.MonitorVisible { + ctx.Session.AddFlash("Monitor is disabled in configuration", "errors") + _ = ctx.Session.Save(req, w) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) + return nil + } + // Get flash messages, save session + errorFlash := ctx.Session.Flashes("errors") + if err = ctx.Session.Save(req, w); err != nil { + return err + } + // Render template + return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{ + "ctx": ctx, + "errorFlash": errorFlash, + }) +} + +// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox +func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + if !ctx.RootConfig.Web.MonitorVisible { + ctx.Session.AddFlash("Monitor is disabled in configuration", "errors") + _ = ctx.Session.Save(req, w) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) + return nil + } + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) + if err != nil { + ctx.Session.AddFlash(err.Error(), "errors") + _ = ctx.Session.Save(req, w) + http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) + return nil + } + // Get flash messages, save session + errorFlash := ctx.Session.Flashes("errors") + if err = ctx.Session.Save(req, w); err != nil { + return err + } + // Render template + return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{ + "ctx": ctx, + "errorFlash": errorFlash, + "name": name, + }) +} + +// RootStatus serves the Inbucket status page +func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + // Get flash messages, save session + errorFlash := ctx.Session.Flashes("errors") + if err = ctx.Session.Save(req, w); err != nil { + return err + } + // Render template + return web.RenderTemplate("root/status.html", w, map[string]interface{}{ + "ctx": ctx, + "errorFlash": errorFlash, + "version": config.Version, + "buildDate": config.BuildDate, + "smtpListener": ctx.RootConfig.SMTP.Addr, + "pop3Listener": ctx.RootConfig.POP3.Addr, + "webListener": ctx.RootConfig.Web.Addr, + "smtpConfig": ctx.RootConfig.SMTP, + "storageConfig": ctx.RootConfig.Storage, + }) +} diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go new file mode 100644 index 0000000..d6da620 --- /dev/null +++ b/pkg/webui/routes.go @@ -0,0 +1,37 @@ +// Package webui powers Inbucket's web GUI +package webui + +import ( + "github.com/gorilla/mux" + "github.com/jhillyerd/inbucket/pkg/server/web" +) + +// SetupRoutes populates routes for the webui into the provided Router +func SetupRoutes(r *mux.Router) { + r.Path("/").Handler( + web.Handler(RootIndex)).Name("RootIndex").Methods("GET") + r.Path("/monitor").Handler( + web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET") + r.Path("/monitor/{name}").Handler( + web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET") + r.Path("/status").Handler( + web.Handler(RootStatus)).Name("RootStatus").Methods("GET") + r.Path("/link/{name}/{id}").Handler( + web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET") + r.Path("/mailbox").Handler( + web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET") + r.Path("/mailbox/{name}").Handler( + web.Handler(MailboxList)).Name("MailboxList").Methods("GET") + r.Path("/mailbox/{name}/{id}").Handler( + web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET") + r.Path("/mailbox/{name}/{id}/html").Handler( + web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET") + r.Path("/mailbox/{name}/{id}/source").Handler( + web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET") + r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler( + web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET") + r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler( + web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET") + r.Path("/{name}").Handler( + web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET") +} diff --git a/sanitize/css.go b/pkg/webui/sanitize/css.go similarity index 100% rename from sanitize/css.go rename to pkg/webui/sanitize/css.go diff --git a/sanitize/css_test.go b/pkg/webui/sanitize/css_test.go similarity index 100% rename from sanitize/css_test.go rename to pkg/webui/sanitize/css_test.go diff --git a/sanitize/html.go b/pkg/webui/sanitize/html.go similarity index 95% rename from sanitize/html.go rename to pkg/webui/sanitize/html.go index 475880f..cd7a7ae 100644 --- a/sanitize/html.go +++ b/pkg/webui/sanitize/html.go @@ -18,6 +18,7 @@ var ( AllowAttrs("style").Matching(cssSafe).Globally() ) +// HTML sanitizes the provided html, while attempting to preserve inline CSS styling. func HTML(html string) (output string, err error) { output, err = sanitizeStyleTags(html) if err != nil { diff --git a/sanitize/html_test.go b/pkg/webui/sanitize/html_test.go similarity index 98% rename from sanitize/html_test.go rename to pkg/webui/sanitize/html_test.go index c6acf09..c5443f4 100644 --- a/sanitize/html_test.go +++ b/pkg/webui/sanitize/html_test.go @@ -3,7 +3,7 @@ package sanitize_test import ( "testing" - "github.com/jhillyerd/inbucket/sanitize" + "github.com/jhillyerd/inbucket/pkg/webui/sanitize" ) // TestHTMLPlainStrings test plain text passthrough diff --git a/pop3d/handler.go b/pop3d/handler.go deleted file mode 100644 index 7f5967e..0000000 --- a/pop3d/handler.go +++ /dev/null @@ -1,656 +0,0 @@ -package pop3d - -import ( - "bufio" - "bytes" - "fmt" - "io" - "net" - "os" - "strconv" - "strings" - "time" - - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/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 *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 - mailbox datastore.Mailbox // Mailbox instance - messages []datastore.Message // Slice of messages in mailbox - retain []bool // Messages to retain upon UPDATE (true=retain) - msgCount int // Number of undeleted messages -} - -// NewSession creates a new POP3 session -func NewSession(server *Server, id int, conn net.Conn) *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} -} - -func (ses *Session) String() string { - return fmt.Sprintf("Session{id: %v, state: %v}", ses.id, ses.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) { - log.Infof("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id) - //expConnectsCurrent.Add(1) - defer func() { - if err := conn.Close(); err != nil { - log.Errorf("Error closing POP3 connection for <%v>: %v", id, err) - } - s.waitgroup.Done() - //expConnectsCurrent.Add(-1) - }() - - ses := NewSession(s, id, conn) - ses.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(), - time.Now().Unix(), s.domain)) - - // This is our command reading loop - for ses.state != QUIT && ses.sendError == nil { - line, err := ses.readLine() - if err == nil { - if cmd, arg, ok := ses.parseCmd(line); ok { - // Check against valid SMTP commands - if cmd == "" { - ses.send("-ERR Speak up") - continue - } - if !commands[cmd] { - ses.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd)) - ses.logWarn("Unrecognized command: %v", cmd) - continue - } - - // Commands we handle in any state - switch cmd { - case "CAPA": - // List our capabilities per RFC2449 - ses.send("+OK Capability list follows") - ses.send("TOP") - ses.send("USER") - ses.send("UIDL") - ses.send("IMPLEMENTATION Inbucket") - ses.send(".") - continue - } - - // Send command to handler for current state - switch ses.state { - case AUTHORIZATION: - ses.authorizationHandler(cmd, arg) - continue - case TRANSACTION: - ses.transactionHandler(cmd, arg) - continue - } - ses.logError("Session entered unexpected state %v", ses.state) - break - } else { - ses.send("-ERR Syntax error, command garbled") - } - } else { - // readLine() returned an error - if err == io.EOF { - switch ses.state { - case AUTHORIZATION: - // EOF is common here - ses.logInfo("Client closed connection (state %v)", ses.state) - default: - ses.logWarn("Got EOF while in state %v", ses.state) - } - break - } - // not an EOF - ses.logWarn("Connection error: %v", err) - if netErr, ok := err.(net.Error); ok { - if netErr.Timeout() { - ses.send("-ERR Idle timeout, bye bye") - break - } - } - ses.send("-ERR Connection error, sorry") - break - } - } - if ses.sendError != nil { - ses.logWarn("Network send error: %v", ses.sendError) - } - ses.logInfo("Closing connection") -} - -// AUTHORIZATION state -func (ses *Session) authorizationHandler(cmd string, args []string) { - switch cmd { - case "QUIT": - ses.send("+OK Goodnight and good luck") - ses.enterState(QUIT) - case "USER": - if len(args) > 0 { - ses.user = args[0] - ses.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", ses.user)) - } else { - ses.send("-ERR Missing username argument") - } - case "PASS": - if ses.user == "" { - ses.ooSeq(cmd) - } else { - var err error - ses.mailbox, err = ses.server.dataStore.MailboxFor(ses.user) - if err != nil { - ses.logError("Failed to open mailbox for %v", ses.user) - ses.send(fmt.Sprintf("-ERR Failed to open mailbox for %v", ses.user)) - ses.enterState(QUIT) - return - } - ses.loadMailbox() - ses.send(fmt.Sprintf("+OK Found %v messages for %v", ses.msgCount, ses.user)) - ses.enterState(TRANSACTION) - } - case "APOP": - if len(args) != 2 { - ses.logWarn("Expected two arguments for APOP") - ses.send("-ERR APOP requires two arguments") - return - } - ses.user = args[0] - var err error - ses.mailbox, err = ses.server.dataStore.MailboxFor(ses.user) - if err != nil { - ses.logError("Failed to open mailbox for %v", ses.user) - ses.send(fmt.Sprintf("-ERR Failed to open mailbox for %v", ses.user)) - ses.enterState(QUIT) - return - } - ses.loadMailbox() - ses.send(fmt.Sprintf("+OK Found %v messages for %v", ses.msgCount, ses.user)) - ses.enterState(TRANSACTION) - default: - ses.ooSeq(cmd) - } -} - -// TRANSACTION state -func (ses *Session) transactionHandler(cmd string, args []string) { - switch cmd { - case "STAT": - if len(args) != 0 { - ses.logWarn("STAT got an unexpected argument") - ses.send("-ERR STAT command must have no arguments") - return - } - var count int - var size int64 - for i, msg := range ses.messages { - if ses.retain[i] { - count++ - size += msg.Size() - } - } - ses.send(fmt.Sprintf("+OK %v %v", count, size)) - case "LIST": - if len(args) > 1 { - ses.logWarn("LIST command had more than 1 argument") - ses.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 { - ses.logWarn("LIST command argument was not an integer") - ses.send("-ERR LIST command requires an integer argument") - return - } - if msgNum < 1 { - ses.logWarn("LIST command argument was less than 1") - ses.send("-ERR LIST argument must be greater than 0") - return - } - if int(msgNum) > len(ses.messages) { - ses.logWarn("LIST command argument was greater than number of messages") - ses.send("-ERR LIST argument must not exceed the number of messages") - return - } - if !ses.retain[msgNum-1] { - ses.logWarn("Client tried to LIST a message it had deleted") - ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum)) - return - } - ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].Size())) - } else { - ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount)) - for i, msg := range ses.messages { - if ses.retain[i] { - ses.send(fmt.Sprintf("%v %v", i+1, msg.Size())) - } - } - ses.send(".") - } - case "UIDL": - if len(args) > 1 { - ses.logWarn("UIDL command had more than 1 argument") - ses.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 { - ses.logWarn("UIDL command argument was not an integer") - ses.send("-ERR UIDL command requires an integer argument") - return - } - if msgNum < 1 { - ses.logWarn("UIDL command argument was less than 1") - ses.send("-ERR UIDL argument must be greater than 0") - return - } - if int(msgNum) > len(ses.messages) { - ses.logWarn("UIDL command argument was greater than number of messages") - ses.send("-ERR UIDL argument must not exceed the number of messages") - return - } - if !ses.retain[msgNum-1] { - ses.logWarn("Client tried to UIDL a message it had deleted") - ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum)) - return - } - ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].ID())) - } else { - ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount)) - for i, msg := range ses.messages { - if ses.retain[i] { - ses.send(fmt.Sprintf("%v %v", i+1, msg.ID())) - } - } - ses.send(".") - } - case "DELE": - if len(args) != 1 { - ses.logWarn("DELE command had invalid number of arguments") - ses.send("-ERR DELE command requires a single argument") - return - } - msgNum, err := strconv.ParseInt(args[0], 10, 32) - if err != nil { - ses.logWarn("DELE command argument was not an integer") - ses.send("-ERR DELE command requires an integer argument") - return - } - if msgNum < 1 { - ses.logWarn("DELE command argument was less than 1") - ses.send("-ERR DELE argument must be greater than 0") - return - } - if int(msgNum) > len(ses.messages) { - ses.logWarn("DELE command argument was greater than number of messages") - ses.send("-ERR DELE argument must not exceed the number of messages") - return - } - if ses.retain[msgNum-1] { - ses.retain[msgNum-1] = false - ses.msgCount-- - ses.send(fmt.Sprintf("+OK Deleted message %v", msgNum)) - } else { - ses.logWarn("Client tried to DELE an already deleted message") - ses.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum)) - } - case "RETR": - if len(args) != 1 { - ses.logWarn("RETR command had invalid number of arguments") - ses.send("-ERR RETR command requires a single argument") - return - } - msgNum, err := strconv.ParseInt(args[0], 10, 32) - if err != nil { - ses.logWarn("RETR command argument was not an integer") - ses.send("-ERR RETR command requires an integer argument") - return - } - if msgNum < 1 { - ses.logWarn("RETR command argument was less than 1") - ses.send("-ERR RETR argument must be greater than 0") - return - } - if int(msgNum) > len(ses.messages) { - ses.logWarn("RETR command argument was greater than number of messages") - ses.send("-ERR RETR argument must not exceed the number of messages") - return - } - ses.send(fmt.Sprintf("+OK %v bytes follows", ses.messages[msgNum-1].Size())) - ses.sendMessage(ses.messages[msgNum-1]) - case "TOP": - if len(args) != 2 { - ses.logWarn("TOP command had invalid number of arguments") - ses.send("-ERR TOP command requires two arguments") - return - } - msgNum, err := strconv.ParseInt(args[0], 10, 32) - if err != nil { - ses.logWarn("TOP command first argument was not an integer") - ses.send("-ERR TOP command requires an integer argument") - return - } - if msgNum < 1 { - ses.logWarn("TOP command first argument was less than 1") - ses.send("-ERR TOP first argument must be greater than 0") - return - } - if int(msgNum) > len(ses.messages) { - ses.logWarn("TOP command first argument was greater than number of messages") - ses.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 { - ses.logWarn("TOP command second argument was not an integer") - ses.send("-ERR TOP command requires an integer argument") - return - } - if lines < 0 { - ses.logWarn("TOP command second argument was negative") - ses.send("-ERR TOP second argument must be non-negative") - return - } - ses.send("+OK Top of message follows") - ses.sendMessageTop(ses.messages[msgNum-1], int(lines)) - case "QUIT": - ses.send("+OK We will process your deletes") - ses.processDeletes() - ses.enterState(QUIT) - case "NOOP": - ses.send("+OK I have sucessfully done nothing") - case "RSET": - // Reset session, don't actually delete anything I told you to - ses.logTrace("Resetting session state on RSET request") - ses.reset() - ses.send("+OK Session reset") - default: - ses.ooSeq(cmd) - } -} - -// Send the contents of the message to the client -func (ses *Session) sendMessage(msg datastore.Message) { - reader, err := msg.RawReader() - if err != nil { - ses.logError("Failed to read message for RETR command") - ses.send("-ERR Failed to RETR that message, internal error") - return - } - defer func() { - if err := reader.Close(); err != nil { - ses.logError("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 - } - ses.send(line) - } - - if err = scanner.Err(); err != nil { - ses.logError("Failed to read message for RETR command") - ses.send(".") - ses.send("-ERR Failed to RETR that message, internal error") - return - } - ses.send(".") -} - -// Send the headers plus the top N lines to the client -func (ses *Session) sendMessageTop(msg datastore.Message, lineCount int) { - reader, err := msg.RawReader() - if err != nil { - ses.logError("Failed to read message for RETR command") - ses.send("-ERR Failed to RETR that message, internal error") - return - } - defer func() { - if err := reader.Close(); err != nil { - ses.logError("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 - } - } - ses.send(line) - } - - if err = scanner.Err(); err != nil { - ses.logError("Failed to read message for RETR command") - ses.send(".") - ses.send("-ERR Failed to RETR that message, internal error") - return - } - ses.send(".") -} - -// Load the users mailbox -func (ses *Session) loadMailbox() { - var err error - ses.messages, err = ses.mailbox.GetMessages() - if err != nil { - ses.logError("Failed to load messages for %v", ses.user) - } - - ses.retainAll() -} - -// Reset retain flag to true for all messages -func (ses *Session) retainAll() { - ses.retain = make([]bool, len(ses.messages)) - for i := range ses.retain { - ses.retain[i] = true - } - ses.msgCount = len(ses.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 (ses *Session) processDeletes() { - ses.logInfo("Processing deletes") - for i, msg := range ses.messages { - if !ses.retain[i] { - ses.logTrace("Deleting %v", msg) - if err := msg.Delete(); err != nil { - ses.logWarn("Error deleting %v: %v", msg, err) - } - } - } -} - -func (ses *Session) enterState(state State) { - ses.state = state - ses.logTrace("Entering state %v", state) -} - -// Calculate the next read or write deadline based on maxIdleSeconds -func (ses *Session) nextDeadline() time.Time { - return time.Now().Add(time.Duration(ses.server.maxIdleSeconds) * time.Second) -} - -// Send requested message, store errors in Session.sendError -func (ses *Session) send(msg string) { - if err := ses.conn.SetWriteDeadline(ses.nextDeadline()); err != nil { - ses.sendError = err - return - } - if _, err := fmt.Fprint(ses.conn, msg+"\r\n"); err != nil { - ses.sendError = err - ses.logWarn("Failed to send: '%v'", msg) - return - } - ses.logTrace(">> %v >>", msg) -} - -// readByteLine reads a line of input into the provided buffer. Does -// not reset the Buffer - please do so prior to calling. -func (ses *Session) readByteLine(buf *bytes.Buffer) error { - if err := ses.conn.SetReadDeadline(ses.nextDeadline()); err != nil { - return err - } - for { - line, err := ses.reader.ReadBytes('\r') - if err != nil { - return err - } - if _, err = buf.Write(line); err != nil { - return err - } - // Read the next byte looking for '\n' - c, err := ses.reader.ReadByte() - if err != nil { - return err - } - if err := buf.WriteByte(c); err != nil { - return err - } - if c == '\n' { - // We've reached the end of the line, return - return nil - } - // Else, keep looking - } - // Should be unreachable -} - -// Reads a line of input -func (ses *Session) readLine() (line string, err error) { - if err = ses.conn.SetReadDeadline(ses.nextDeadline()); err != nil { - return "", err - } - line, err = ses.reader.ReadString('\n') - if err != nil { - return "", err - } - ses.logTrace("<< %v <<", strings.TrimRight(line, "\r\n")) - return line, nil -} - -func (ses *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 (ses *Session) reset() { - ses.retainAll() -} - -func (ses *Session) ooSeq(cmd string) { - ses.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd)) - ses.logWarn("Wasn't expecting %v here", cmd) -} - -// Session specific logging methods -func (ses *Session) logTrace(msg string, args ...interface{}) { - log.Tracef("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...)) -} - -func (ses *Session) logInfo(msg string, args ...interface{}) { - log.Infof("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...)) -} - -func (ses *Session) logWarn(msg string, args ...interface{}) { - // Update metrics - //expWarnsTotal.Add(1) - log.Warnf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...)) -} - -func (ses *Session) logError(msg string, args ...interface{}) { - // Update metrics - //expErrorsTotal.Add(1) - log.Errorf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...)) -} diff --git a/pop3d/listener.go b/pop3d/listener.go deleted file mode 100644 index 58b0d82..0000000 --- a/pop3d/listener.go +++ /dev/null @@ -1,123 +0,0 @@ -package pop3d - -import ( - "context" - "fmt" - "net" - "sync" - "time" - - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/log" -) - -// Server defines an instance of our POP3 server -type Server struct { - host string - domain string - maxIdleSeconds int - dataStore datastore.DataStore - listener net.Listener - globalShutdown chan bool - waitgroup *sync.WaitGroup -} - -// New creates a new Server struct -func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server { - return &Server{ - host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port), - domain: cfg.Domain, - dataStore: ds, - maxIdleSeconds: cfg.MaxIdleSeconds, - globalShutdown: shutdownChan, - waitgroup: new(sync.WaitGroup), - } -} - -// Start the server and listen for connections -func (s *Server) Start(ctx context.Context) { - addr, err := net.ResolveTCPAddr("tcp4", s.host) - if err != nil { - log.Errorf("POP3 Failed to build tcp4 address: %v", err) - s.emergencyShutdown() - return - } - - log.Infof("POP3 listening on TCP4 %v", addr) - s.listener, err = net.ListenTCP("tcp4", addr) - if err != nil { - log.Errorf("POP3 failed to start tcp4 listener: %v", err) - s.emergencyShutdown() - return - } - - // Listener go routine - go s.serve(ctx) - - // Wait for shutdown - select { - case _ = <-ctx.Done(): - } - - log.Tracef("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 { - log.Errorf("Error closing POP3 listener: %v", err) - } -} - -// 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.Errorf("POP3 accept error: %v; retrying in %v", err, 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.waitgroup.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.waitgroup.Wait() - log.Tracef("POP3 connections have drained") -} diff --git a/rest/apiv1_controller.go b/rest/apiv1_controller.go deleted file mode 100644 index 9037eb1..0000000 --- a/rest/apiv1_controller.go +++ /dev/null @@ -1,201 +0,0 @@ -package rest - -import ( - "fmt" - "io" - "net/http" - - "crypto/md5" - "encoding/hex" - "io/ioutil" - "strconv" - - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/httpd" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/rest/model" - "github.com/jhillyerd/inbucket/stringutil" -) - -// MailboxListV1 renders a list of messages in a mailbox -func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) - if err != nil { - return err - } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - messages, err := mb.GetMessages() - if err != nil { - // This doesn't indicate empty, likely an IO error - return fmt.Errorf("Failed to get messages for %v: %v", name, err) - } - log.Tracef("Got %v messsages", len(messages)) - - jmessages := make([]*model.JSONMessageHeaderV1, len(messages)) - for i, msg := range messages { - jmessages[i] = &model.JSONMessageHeaderV1{ - Mailbox: name, - ID: msg.ID(), - From: msg.From(), - To: msg.To(), - Subject: msg.Subject(), - Date: msg.Date(), - Size: msg.Size(), - } - } - return httpd.RenderJSON(w, jmessages) -} - -// MailboxShowV1 renders a particular message from a mailbox -func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - id := ctx.Vars["id"] - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) - if err != nil { - return err - } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - msg, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { - http.NotFound(w, req) - return nil - } - if err != nil { - // This doesn't indicate empty, likely an IO error - return fmt.Errorf("GetMessage(%q) failed: %v", id, err) - } - header, err := msg.ReadHeader() - if err != nil { - return fmt.Errorf("ReadHeader(%q) failed: %v", id, err) - } - mime, err := msg.ReadBody() - if err != nil { - return fmt.Errorf("ReadBody(%q) failed: %v", id, err) - } - - attachments := make([]*model.JSONMessageAttachmentV1, len(mime.Attachments)) - for i, att := range mime.Attachments { - var content []byte - content, err = ioutil.ReadAll(att) - var checksum = md5.Sum(content) - attachments[i] = &model.JSONMessageAttachmentV1{ - ContentType: att.ContentType, - FileName: att.FileName, - DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName, - ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName, - MD5: hex.EncodeToString(checksum[:]), - } - } - - return httpd.RenderJSON(w, - &model.JSONMessageV1{ - Mailbox: name, - ID: msg.ID(), - From: msg.From(), - To: msg.To(), - Subject: msg.Subject(), - Date: msg.Date(), - Size: msg.Size(), - Header: header.Header, - Body: &model.JSONMessageBodyV1{ - Text: mime.Text, - HTML: mime.HTML, - }, - Attachments: attachments, - }) -} - -// MailboxPurgeV1 deletes all messages from a mailbox -func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) - if err != nil { - return err - } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - // Delete all messages - err = mb.Purge() - if err != nil { - return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err) - } - log.Tracef("HTTP purged mailbox for %q", name) - - return httpd.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 *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - id := ctx.Vars["id"] - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) - if err != nil { - return err - } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { - http.NotFound(w, req) - return nil - } - if err != nil { - // This doesn't indicate missing, likely an IO error - return fmt.Errorf("GetMessage(%q) failed: %v", id, err) - } - raw, err := message.ReadRaw() - if err != nil { - return fmt.Errorf("ReadRaw(%q) failed: %v", id, err) - } - - w.Header().Set("Content-Type", "text/plain") - if _, err := io.WriteString(w, *raw); err != nil { - return err - } - return nil -} - -// MailboxDeleteV1 removes a particular message from a mailbox -func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - // Don't have to validate these aren't empty, Gorilla returns 404 - id := ctx.Vars["id"] - name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) - if err != nil { - return err - } - mb, err := ctx.DataStore.MailboxFor(name) - if err != nil { - // This doesn't indicate not found, likely an IO error - return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) - } - message, err := mb.GetMessage(id) - if err == datastore.ErrNotExist { - http.NotFound(w, req) - return nil - } - if err != nil { - // This doesn't indicate missing, likely an IO error - return fmt.Errorf("GetMessage(%q) failed: %v", id, err) - } - err = message.Delete() - if err != nil { - return fmt.Errorf("Delete(%q) failed: %v", id, err) - } - - return httpd.RenderJSON(w, "OK") -} diff --git a/rest/apiv1_controller_test.go b/rest/apiv1_controller_test.go deleted file mode 100644 index b1c9a00..0000000 --- a/rest/apiv1_controller_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package rest - -import ( - "encoding/json" - "fmt" - "io" - "net/mail" - "os" - "testing" - "time" - - "github.com/jhillyerd/inbucket/datastore" -) - -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 - ds := &datastore.MockDataStore{} - logbuf := setupWebServer(ds) - - // Test invalid mailbox name - w, err := testRestGet(baseURL + "/mailbox/foo@bar") - 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 - emptybox := &datastore.MockMailbox{} - ds.On("MailboxFor", "empty").Return(emptybox, nil) - emptybox.On("GetMessages").Return([]datastore.Message{}, nil) - - 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 MailboxFor error - ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error")) - w, err = testRestGet(baseURL + "/mailbox/error") - 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 MailboxFor error - error2box := &datastore.MockMailbox{} - ds.On("MailboxFor", "error2").Return(error2box, nil) - error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2")) - - w, err = testRestGet(baseURL + "/mailbox/error2") - 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 - data1 := &InputMessageData{ - Mailbox: "good", - ID: "0001", - From: "from1", - To: []string{"to1"}, - Subject: "subject 1", - Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)), - } - data2 := &InputMessageData{ - Mailbox: "good", - ID: "0002", - From: "from2", - To: []string{"to1"}, - Subject: "subject 2", - Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)), - } - goodbox := &datastore.MockMailbox{} - ds.On("MailboxFor", "good").Return(goodbox, nil) - msg1 := data1.MockMessage() - msg2 := data2.MockMessage() - goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil) - - // 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.Errorf("Expected 2 results, got %v", len(result)) - } - if errors := data1.CompareToJSONHeaderMap(result[0]); len(errors) > 0 { - t.Logf("%v", result[0]) - for _, e := range errors { - t.Error(e) - } - } - if errors := data2.CompareToJSONHeaderMap(result[1]); len(errors) > 0 { - t.Logf("%v", result[1]) - for _, e := range errors { - t.Error(e) - } - } - - 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 - ds := &datastore.MockDataStore{} - logbuf := setupWebServer(ds) - - // Test invalid mailbox name - w, err := testRestGet(baseURL + "/mailbox/foo@bar/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 - emptybox := &datastore.MockMailbox{} - ds.On("MailboxFor", "empty").Return(emptybox, nil) - emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist) - - 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 MailboxFor error - ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error")) - w, err = testRestGet(baseURL + "/mailbox/error/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 GetMessage error - error2box := &datastore.MockMailbox{} - ds.On("MailboxFor", "error2").Return(error2box, nil) - error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2")) - - w, err = testRestGet(baseURL + "/mailbox/error2/0001") - 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 - data1 := &InputMessageData{ - Mailbox: "good", - ID: "0001", - From: "from1", - Subject: "subject 1", - Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)), - Header: mail.Header{ - "To": []string{"fred@fish.com", "keyword@nsa.gov"}, - "From": []string{"noreply@inbucket.org"}, - }, - Text: "This is some text", - HTML: "This is some HTML", - } - goodbox := &datastore.MockMailbox{} - ds.On("MailboxFor", "good").Return(goodbox, nil) - msg1 := data1.MockMessage() - goodbox.On("GetMessage", "0001").Return(msg1, nil) - - // 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) - } - - if errors := data1.CompareToJSONMessageMap(result); len(errors) > 0 { - t.Logf("%v", result) - for _, e := range errors { - t.Error(e) - } - } - - 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) - } -} diff --git a/rest/routes.go b/rest/routes.go deleted file mode 100644 index 1bc73d1..0000000 --- a/rest/routes.go +++ /dev/null @@ -1,23 +0,0 @@ -package rest - -import "github.com/gorilla/mux" -import "github.com/jhillyerd/inbucket/httpd" - -// SetupRoutes populates the routes for the REST interface -func SetupRoutes(r *mux.Router) { - // API v1 - r.Path("/api/v1/mailbox/{name}").Handler( - httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET") - r.Path("/api/v1/mailbox/{name}").Handler( - httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE") - r.Path("/api/v1/mailbox/{name}/{id}").Handler( - httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET") - r.Path("/api/v1/mailbox/{name}/{id}").Handler( - httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE") - r.Path("/api/v1/mailbox/{name}/{id}/source").Handler( - httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET") - r.Path("/api/v1/monitor/messages").Handler( - httpd.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET") - r.Path("/api/v1/monitor/messages/{name}").Handler( - httpd.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET") -} diff --git a/rest/testutils_test.go b/rest/testutils_test.go deleted file mode 100644 index 6ef4182..0000000 --- a/rest/testutils_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package rest - -import ( - "bytes" - "fmt" - "log" - "net/http" - "net/http/httptest" - "net/mail" - "time" - - "github.com/jhillyerd/enmime" - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/httpd" - "github.com/jhillyerd/inbucket/msghub" -) - -type InputMessageData struct { - Mailbox, ID, From, Subject string - To []string - Date time.Time - Size int - Header mail.Header - HTML, Text string -} - -func (d *InputMessageData) MockMessage() *datastore.MockMessage { - msg := &datastore.MockMessage{} - msg.On("ID").Return(d.ID) - msg.On("From").Return(d.From) - msg.On("To").Return(d.To) - msg.On("Subject").Return(d.Subject) - msg.On("Date").Return(d.Date) - msg.On("Size").Return(d.Size) - gomsg := &mail.Message{ - Header: d.Header, - } - msg.On("ReadHeader").Return(gomsg, nil) - body := &enmime.Envelope{ - Text: d.Text, - HTML: d.HTML, - } - msg.On("ReadBody").Return(body, nil) - return msg -} - -// isJSONStringEqual is a utility function to return a nicely formatted message when -// comparing a string to a value received from a JSON map. -func isJSONStringEqual(key, expected string, received interface{}) (message string, ok bool) { - if value, ok := received.(string); ok { - if expected == value { - return "", true - } - return fmt.Sprintf("Expected value of key %v to be %q, got %q", key, expected, value), false - } - return fmt.Sprintf("Expected value of key %v to be a string, got %T", key, received), false -} - -// isJSONNumberEqual is a utility function to return a nicely formatted message when -// comparing an float64 to a value received from a JSON map. -func isJSONNumberEqual(key string, expected float64, received interface{}) (message string, ok bool) { - if value, ok := received.(float64); ok { - if expected == value { - return "", true - } - return fmt.Sprintf("Expected %v to be %v, got %v", key, expected, value), false - } - return fmt.Sprintf("Expected %v to be a string, got %T", key, received), false -} - -// CompareToJSONHeaderMap compares InputMessageData to a header map decoded from JSON, -// returning a list of things that did not match. -func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []string) { - if m, ok := json.(map[string]interface{}); ok { - if msg, ok := isJSONStringEqual(mailboxKey, d.Mailbox, m[mailboxKey]); !ok { - errors = append(errors, msg) - } - if msg, ok := isJSONStringEqual(idKey, d.ID, m[idKey]); !ok { - errors = append(errors, msg) - } - if msg, ok := isJSONStringEqual(fromKey, d.From, m[fromKey]); !ok { - errors = append(errors, msg) - } - for i, inputTo := range d.To { - if msg, ok := isJSONStringEqual(toKey, inputTo, m[toKey].([]interface{})[i]); !ok { - errors = append(errors, msg) - } - } - if msg, ok := isJSONStringEqual(subjectKey, d.Subject, m[subjectKey]); !ok { - errors = append(errors, msg) - } - exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00") - if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok { - errors = append(errors, msg) - } - if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok { - errors = append(errors, msg) - } - return errors - } - panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json)) -} - -// CompareToJSONMessageMap compares InputMessageData to a message map decoded from JSON, -// returning a list of things that did not match. -func (d *InputMessageData) CompareToJSONMessageMap(json interface{}) (errors []string) { - // We need to check the same values as header first - errors = d.CompareToJSONHeaderMap(json) - - if m, ok := json.(map[string]interface{}); ok { - // Get nested body map - if m[bodyKey] != nil { - if body, ok := m[bodyKey].(map[string]interface{}); ok { - if msg, ok := isJSONStringEqual(textKey, d.Text, body[textKey]); !ok { - errors = append(errors, msg) - } - if msg, ok := isJSONStringEqual(htmlKey, d.HTML, body[htmlKey]); !ok { - errors = append(errors, msg) - } - } else { - panic(fmt.Sprintf("Expected map[string]interface{} in json key %q, got %T", - bodyKey, m[bodyKey])) - } - } else { - errors = append(errors, fmt.Sprintf("Expected body in JSON %q but it was nil", bodyKey)) - } - exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00") - if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok { - errors = append(errors, msg) - } - if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok { - errors = append(errors, msg) - } - - // Get nested header map - if m[headerKey] != nil { - if header, ok := m[headerKey].(map[string]interface{}); ok { - // Loop over input (expected) header names - for name, keyInputHeaders := range d.Header { - // Make sure expected header name exists in received JSON - if keyOutputVals, ok := header[name]; ok { - if keyOutputHeaders, ok := keyOutputVals.([]interface{}); ok { - // Loop over input (expected) header values - for _, inputHeader := range keyInputHeaders { - hasValue := false - // Look for expected value in received headers - for _, outputHeader := range keyOutputHeaders { - if inputHeader == outputHeader { - hasValue = true - break - } - } - if !hasValue { - errors = append(errors, fmt.Sprintf( - "JSON %v[%q] missing value %q", headerKey, name, inputHeader)) - } - } - } else { - // keyOutputValues was not a slice of interface{} - panic(fmt.Sprintf("Expected []interface{} in %v[%q], got %T", headerKey, - name, keyOutputVals)) - } - } else { - errors = append(errors, fmt.Sprintf("JSON %v missing key %q", headerKey, name)) - } - } - } - } else { - errors = append(errors, fmt.Sprintf("Expected header in JSON %q but it was nil", headerKey)) - } - } else { - panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json)) - } - - return errors -} - -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() - httpd.Router.ServeHTTP(w, req) - return w, nil -} - -func setupWebServer(ds datastore.DataStore) *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.WebConfig{ - TemplateDir: "../themes/bootstrap/templates", - PublicDir: "../themes/bootstrap/public", - } - shutdownChan := make(chan bool) - httpd.Initialize(cfg, shutdownChan, ds, &msghub.Hub{}) - SetupRoutes(httpd.Router) - - return buf -} diff --git a/smtpd/handler.go b/smtpd/handler.go deleted file mode 100644 index cbae977..0000000 --- a/smtpd/handler.go +++ /dev/null @@ -1,628 +0,0 @@ -package smtpd - -import ( - "bufio" - "bytes" - "container/list" - "fmt" - "io" - "net" - "regexp" - "strconv" - "strings" - "time" - - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/msghub" - "github.com/jhillyerd/inbucket/stringutil" -) - -// State tracks the current mode of our SMTP state machine -type State int - -const ( - // 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 -) - -const timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)" - -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, -} - -// recipientDetails for message delivery -type recipientDetails struct { - address, localPart, domainPart string - mailbox datastore.Mailbox -} - -// Session holds the state of an SMTP session -type Session struct { - server *Server - id int - conn net.Conn - remoteDomain string - remoteHost string - sendError error - state State - reader *bufio.Reader - from string - recipients *list.List -} - -// NewSession creates a new Session for the given connection -func NewSession(server *Server, id int, conn net.Conn) *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} -} - -func (ss *Session) String() string { - return fmt.Sprintf("Session{id: %v, state: %v}", ss.id, ss.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) { - log.Infof("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id) - expConnectsCurrent.Add(1) - defer func() { - if err := conn.Close(); err != nil { - log.Errorf("Error closing connection for <%v>: %v", id, err) - } - s.waitgroup.Done() - expConnectsCurrent.Add(-1) - }() - - ss := NewSession(s, id, conn) - ss.greet() - - // This is our command reading loop - for ss.state != QUIT && ss.sendError == nil { - if ss.state == DATA { - // Special case, does not use SMTP command format - ss.dataHandler() - continue - } - line, err := ss.readLine() - if err == nil { - if cmd, arg, ok := ss.parseCmd(line); ok { - // Check against valid SMTP commands - if cmd == "" { - ss.send("500 Speak up") - continue - } - if !commands[cmd] { - ss.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd)) - ss.logWarn("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 - ss.send(fmt.Sprintf("502 %v command not implemented", cmd)) - ss.logWarn("Command %v not implemented by Inbucket", cmd) - continue - case "VRFY": - ss.send("252 Cannot VRFY user, but will accept message") - continue - case "NOOP": - ss.send("250 I have sucessfully done nothing") - continue - case "RSET": - // Reset session - ss.logTrace("Resetting session state on RSET request") - ss.reset() - ss.send("250 Session reset") - continue - case "QUIT": - ss.send("221 Goodnight and good luck") - ss.enterState(QUIT) - continue - } - - // Send command to handler for current state - switch ss.state { - case GREET: - ss.greetHandler(cmd, arg) - continue - case READY: - ss.readyHandler(cmd, arg) - continue - case MAIL: - ss.mailHandler(cmd, arg) - continue - } - ss.logError("Session entered unexpected state %v", ss.state) - break - } else { - ss.send("500 Syntax error, command garbled") - } - } else { - // readLine() returned an error - if err == io.EOF { - switch ss.state { - case GREET, READY: - // EOF is common here - ss.logInfo("Client closed connection (state %v)", ss.state) - default: - ss.logWarn("Got EOF while in state %v", ss.state) - } - break - } - // not an EOF - ss.logWarn("Connection error: %v", err) - if netErr, ok := err.(net.Error); ok { - if netErr.Timeout() { - ss.send("221 Idle timeout, bye bye") - break - } - } - ss.send("221 Connection error, sorry") - break - } - } - if ss.sendError != nil { - ss.logWarn("Network send error: %v", ss.sendError) - } - ss.logInfo("Closing connection") -} - -// GREET state -> waiting for HELO -func (ss *Session) greetHandler(cmd string, arg string) { - switch cmd { - case "HELO": - domain, err := parseHelloArgument(arg) - if err != nil { - ss.send("501 Domain/address argument required for HELO") - return - } - ss.remoteDomain = domain - ss.send("250 Great, let's get this show on the road") - ss.enterState(READY) - case "EHLO": - domain, err := parseHelloArgument(arg) - if err != nil { - ss.send("501 Domain/address argument required for EHLO") - return - } - ss.remoteDomain = domain - ss.send("250-Great, let's get this show on the road") - ss.send("250-8BITMIME") - ss.send(fmt.Sprintf("250 SIZE %v", ss.server.maxMessageBytes)) - ss.enterState(READY) - default: - ss.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 (ss *Session) readyHandler(cmd string, arg string) { - if cmd == "MAIL" { - // Match FROM, while accepting '>' as quoted pair and in double quoted strings - // (?i) makes the regex case insensitive, (?:) is non-grouping sub-match - re := regexp.MustCompile("(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$") - m := re.FindStringSubmatch(arg) - if m == nil { - ss.send("501 Was expecting MAIL arg syntax of FROM:
") - ss.logWarn("Bad MAIL argument: %q", arg) - return - } - from := m[1] - if _, _, err := stringutil.ParseEmailAddress(from); err != nil { - ss.send("501 Bad sender address syntax") - ss.logWarn("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 := ss.parseArgs(m[2]) - if !ok { - ss.send("501 Unable to parse MAIL ESMTP parameters") - ss.logWarn("Bad MAIL argument: %q", arg) - return - } - if args["SIZE"] != "" { - size, err := strconv.ParseInt(args["SIZE"], 10, 32) - if err != nil { - ss.send("501 Unable to parse SIZE as an integer") - ss.logWarn("Unable to parse SIZE %q as an integer", args["SIZE"]) - return - } - if int(size) > ss.server.maxMessageBytes { - ss.send("552 Max message size exceeded") - ss.logWarn("Client wanted to send oversized message: %v", args["SIZE"]) - return - } - } - } - ss.from = from - ss.recipients = list.New() - ss.logInfo("Mail from: %v", from) - ss.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from)) - ss.enterState(MAIL) - } else { - ss.ooSeq(cmd) - } -} - -// MAIL state -> waiting for RCPTs followed by DATA -func (ss *Session) mailHandler(cmd string, arg string) { - switch cmd { - case "RCPT": - if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") { - ss.send("501 Was expecting RCPT arg syntax of TO:
") - ss.logWarn("Bad RCPT argument: %q", arg) - return - } - // This trim is probably too forgiving - recip := strings.Trim(arg[3:], "<> ") - if _, _, err := stringutil.ParseEmailAddress(recip); err != nil { - ss.send("501 Bad recipient address syntax") - ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err) - return - } - if ss.recipients.Len() >= ss.server.maxRecips { - ss.logWarn("Maximum limit of %v recipients reached", ss.server.maxRecips) - ss.send(fmt.Sprintf("552 Maximum limit of %v recipients reached", ss.server.maxRecips)) - return - } - ss.recipients.PushBack(recip) - ss.logInfo("Recipient: %v", recip) - ss.send(fmt.Sprintf("250 I'll make sure <%v> gets this", recip)) - return - case "DATA": - if arg != "" { - ss.send("501 DATA command should not have any arguments") - ss.logWarn("Got unexpected args on DATA: %q", arg) - return - } - if ss.recipients.Len() > 0 { - // We have recipients, go to accept data - ss.enterState(DATA) - return - } - // DATA out of sequence - ss.ooSeq(cmd) - return - } - ss.ooSeq(cmd) -} - -// DATA -func (ss *Session) dataHandler() { - recipients := make([]recipientDetails, 0, ss.recipients.Len()) - // Get a Mailbox and a new Message for each recipient - msgSize := 0 - if ss.server.storeMessages { - for e := ss.recipients.Front(); e != nil; e = e.Next() { - recip := e.Value.(string) - local, domain, err := stringutil.ParseEmailAddress(recip) - if err != nil { - ss.logError("Failed to parse address for %q", recip) - ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip)) - ss.reset() - return - } - if strings.ToLower(domain) != ss.server.domainNoStore { - // Not our "no store" domain, so store the message - mb, err := ss.server.dataStore.MailboxFor(local) - if err != nil { - ss.logError("Failed to open mailbox for %q: %s", local, err) - ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", local)) - ss.reset() - return - } - recipients = append(recipients, recipientDetails{recip, local, domain, mb}) - } else { - log.Tracef("Not storing message for %q", recip) - } - } - } - - ss.send("354 Start mail input; end with .") - var lineBuf bytes.Buffer - msgBuf := make([][]byte, 0, 1024) - for { - lineBuf.Reset() - err := ss.readByteLine(&lineBuf) - if err != nil { - if netErr, ok := err.(net.Error); ok { - if netErr.Timeout() { - ss.send("221 Idle timeout, bye bye") - } - } - ss.logWarn("Error: %v while reading", err) - ss.enterState(QUIT) - return - } - line := lineBuf.Bytes() - // ss.logTrace("DATA: %q", line) - if string(line) == ".\r\n" || string(line) == ".\n" { - // Mail data complete - if ss.server.storeMessages { - // Create a message for each valid recipient - for _, r := range recipients { - // TODO temporary hack to fix #77 until datastore revamp - mu, err := ss.server.dataStore.LockFor(r.localPart) - if err != nil { - ss.logError("Failed to get lock for %q: %s", r.localPart, err) - // Delivery failure - ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart)) - ss.reset() - return - } - mu.Lock() - ok := ss.deliverMessage(r, msgBuf) - mu.Unlock() - if ok { - expReceivedTotal.Add(1) - } else { - // Delivery failure - ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart)) - ss.reset() - return - } - } - } else { - expReceivedTotal.Add(1) - } - ss.send("250 Mail accepted for delivery") - ss.logInfo("Message size %v bytes", msgSize) - ss.reset() - return - } - // SMTP RFC says remove leading periods from input - if len(line) > 0 && line[0] == '.' { - line = line[1:] - } - // Second append copies line/lineBuf so we can reuse it - msgBuf = append(msgBuf, append([]byte{}, line...)) - msgSize += len(line) - if msgSize > ss.server.maxMessageBytes { - // Max message size exceeded - ss.send("552 Maximum message size exceeded") - ss.logWarn("Max message size exceeded while in DATA") - ss.reset() - // Should really cleanup the crap on filesystem (after issue #23) - return - } - } // end for -} - -// deliverMessage creates and populates a new Message for the specified recipient -func (ss *Session) deliverMessage(r recipientDetails, msgBuf [][]byte) (ok bool) { - msg, err := r.mailbox.NewMessage() - if err != nil { - ss.logError("Failed to create message for %q: %s", r.localPart, err) - return false - } - - // Generate Received header - stamp := time.Now().Format(timeStampFormat) - recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n", - ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp) - if err := msg.Append([]byte(recd)); err != nil { - ss.logError("Failed to write received header for %q: %s", r.localPart, err) - return false - } - - // Append lines from msgBuf - for _, line := range msgBuf { - if err := msg.Append(line); err != nil { - ss.logError("Failed to append to mailbox %v: %v", r.mailbox, err) - // Should really cleanup the crap on filesystem - return false - } - } - if err := msg.Close(); err != nil { - ss.logError("Error while closing message for %v: %v", r.mailbox, err) - return false - } - - // Broadcast message information - broadcast := msghub.Message{ - Mailbox: r.mailbox.Name(), - ID: msg.ID(), - From: msg.From(), - To: msg.To(), - Subject: msg.Subject(), - Date: msg.Date(), - Size: msg.Size(), - } - ss.server.msgHub.Dispatch(broadcast) - - return true -} - -func (ss *Session) enterState(state State) { - ss.state = state - ss.logTrace("Entering state %v", state) -} - -func (ss *Session) greet() { - ss.send(fmt.Sprintf("220 %v Inbucket SMTP ready", ss.server.domain)) -} - -// Calculate the next read or write deadline based on maxIdleSeconds -func (ss *Session) nextDeadline() time.Time { - return time.Now().Add(time.Duration(ss.server.maxIdleSeconds) * time.Second) -} - -// Send requested message, store errors in Session.sendError -func (ss *Session) send(msg string) { - if err := ss.conn.SetWriteDeadline(ss.nextDeadline()); err != nil { - ss.sendError = err - return - } - if _, err := fmt.Fprint(ss.conn, msg+"\r\n"); err != nil { - ss.sendError = err - ss.logWarn("Failed to send: %q", msg) - return - } - ss.logTrace(">> %v >>", msg) -} - -// readByteLine reads a line of input into the provided buffer. Does -// not reset the Buffer - please do so prior to calling. -func (ss *Session) readByteLine(buf io.Writer) error { - if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil { - return err - } - line, err := ss.reader.ReadBytes('\n') - if err != nil { - return err - } - _, err = buf.Write(line) - return err -} - -// Reads a line of input -func (ss *Session) readLine() (line string, err error) { - if err = ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil { - return "", err - } - line, err = ss.reader.ReadString('\n') - if err != nil { - return "", err - } - ss.logTrace("<< %v <<", strings.TrimRight(line, "\r\n")) - return line, nil -} - -func (ss *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: - ss.logWarn("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 - ss.logWarn("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? - ss.logWarn("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 (ss *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 { - ss.logWarn("Failed to parse arg string: %q") - return nil, false - } - for _, m := range pm { - args[strings.ToUpper(m[1])] = m[2] - } - ss.logTrace("ESMTP params: %v", args) - return args, true -} - -func (ss *Session) reset() { - ss.enterState(READY) - ss.from = "" - ss.recipients = nil -} - -func (ss *Session) ooSeq(cmd string) { - ss.send(fmt.Sprintf("503 Command %v is out of sequence", cmd)) - ss.logWarn("Wasn't expecting %v here", cmd) -} - -// Session specific logging methods -func (ss *Session) logTrace(msg string, args ...interface{}) { - log.Tracef("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...)) -} - -func (ss *Session) logInfo(msg string, args ...interface{}) { - log.Infof("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...)) -} - -func (ss *Session) logWarn(msg string, args ...interface{}) { - // Update metrics - expWarnsTotal.Add(1) - log.Warnf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...)) -} - -func (ss *Session) logError(msg string, args ...interface{}) { - // Update metrics - expErrorsTotal.Add(1) - log.Errorf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...)) -} diff --git a/smtpd/listener.go b/smtpd/listener.go deleted file mode 100644 index 60953b6..0000000 --- a/smtpd/listener.go +++ /dev/null @@ -1,199 +0,0 @@ -package smtpd - -import ( - "container/list" - "context" - "expvar" - "fmt" - "net" - "strings" - "sync" - "time" - - "github.com/jhillyerd/inbucket/config" - "github.com/jhillyerd/inbucket/datastore" - "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/msghub" -) - -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) - - log.AddTickerFunc(func() { - expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal)) - expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal)) - expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal)) - expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal)) - }) -} - -// Server holds the configuration and state of our SMTP server -type Server struct { - // Configuration - host string - domain string - domainNoStore string - maxRecips int - maxIdleSeconds int - maxMessageBytes int - storeMessages bool - - // Dependencies - dataStore datastore.DataStore // Mailbox/message store - globalShutdown chan bool // Shuts down Inbucket - msgHub *msghub.Hub // Pub/sub for message info - retentionScanner *datastore.RetentionScanner // Deletes expired messages - - // State - listener net.Listener // Incoming network connections - waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions -} - -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) -) - -// NewServer creates a new Server instance with the specificed config -func NewServer( - cfg config.SMTPConfig, - globalShutdown chan bool, - ds datastore.DataStore, - msgHub *msghub.Hub) *Server { - return &Server{ - host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port), - domain: cfg.Domain, - domainNoStore: strings.ToLower(cfg.DomainNoStore), - maxRecips: cfg.MaxRecipients, - maxIdleSeconds: cfg.MaxIdleSeconds, - maxMessageBytes: cfg.MaxMessageBytes, - storeMessages: cfg.StoreMessages, - globalShutdown: globalShutdown, - dataStore: ds, - msgHub: msgHub, - retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown), - waitgroup: new(sync.WaitGroup), - } -} - -// Start the listener and handle incoming connections -func (s *Server) Start(ctx context.Context) { - addr, err := net.ResolveTCPAddr("tcp4", s.host) - if err != nil { - log.Errorf("Failed to build tcp4 address: %v", err) - s.emergencyShutdown() - return - } - - log.Infof("SMTP listening on TCP4 %v", addr) - s.listener, err = net.ListenTCP("tcp4", addr) - if err != nil { - log.Errorf("SMTP failed to start tcp4 listener: %v", err) - s.emergencyShutdown() - return - } - - if !s.storeMessages { - log.Infof("Load test mode active, messages will not be stored") - } else if s.domainNoStore != "" { - log.Infof("Messages sent to domain '%v' will be discarded", s.domainNoStore) - } - - // Start retention scanner - s.retentionScanner.Start() - - // Listener go routine - go s.serve(ctx) - - // Wait for shutdown - <-ctx.Done() - log.Tracef("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 { - log.Errorf("Failed to close SMTP listener: %v", err) - } -} - -// 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.Errorf("SMTP accept error: %v; retrying in %v", err, 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.waitgroup.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.waitgroup.Wait() - log.Tracef("SMTP connections have drained") - s.retentionScanner.Join() -} diff --git a/stringutil/utils_test.go b/stringutil/utils_test.go deleted file mode 100644 index 330bfbd..0000000 --- a/stringutil/utils_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package stringutil - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestParseMailboxName(t *testing.T) { - var validTable = []struct { - input string - expect string - }{ - {"mailbox", "mailbox"}, - {"user123", "user123"}, - {"MailBOX", "mailbox"}, - {"First.Last", "first.last"}, - {"user+label", "user"}, - {"chars!#$%", "chars!#$%"}, - {"chars&'*-", "chars&'*-"}, - {"chars=/?^", "chars=/?^"}, - {"chars_`.{", "chars_`.{"}, - {"chars|}~", "chars|}~"}, - } - - for _, tt := range validTable { - if result, err := ParseMailboxName(tt.input); err != nil { - t.Errorf("Error while parsing %q: %v", tt.input, err) - } else { - if result != tt.expect { - t.Errorf("Parsing %q, expected %q, got %q", tt.input, tt.expect, result) - } - } - } - - var invalidTable = []struct { - input, msg string - }{ - {"", "Empty mailbox name is not permitted"}, - {"user@host", "@ symbol not permitted"}, - {"first last", "Space not permitted"}, - {"first\"last", "Double quote not permitted"}, - {"first\nlast", "Control chars not permitted"}, - } - - for _, tt := range invalidTable { - if _, err := ParseMailboxName(tt.input); err == nil { - t.Errorf("Didn't get an error while parsing %q: %v", tt.input, tt.msg) - } - } -} - -func TestHashMailboxName(t *testing.T) { - assert.Equal(t, HashMailboxName("mail"), "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e") -} - -func TestValidateDomain(t *testing.T) { - assert.False(t, ValidateDomainPart(strings.Repeat("a", 256)), - "Max domain length is 255") - assert.False(t, ValidateDomainPart(strings.Repeat("a", 64)+".com"), - "Max label length is 63") - assert.True(t, ValidateDomainPart(strings.Repeat("a", 63)+".com"), - "Should allow 63 char label") - - var 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"}, - } - - for _, tt := range testTable { - if ValidateDomainPart(tt.input) != tt.expect { - t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg) - } - } -} - -func TestValidateLocal(t *testing.T) { - var 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 := 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) - } - } -} - -func TestParseEmailAddress(t *testing.T) { - // Test some good email addresses - var testTable = []struct { - input, local, domain string - }{ - {"root@localhost", "root", "localhost"}, - {"FirstLast@domain.local", "FirstLast", "domain.local"}, - {"route66@prodigy.net", "route66", "prodigy.net"}, - {"lorbit!user@uucp", "lorbit!user", "uucp"}, - {"user+spam@gmail.com", "user+spam", "gmail.com"}, - {"first.last@domain.local", "first.last", "domain.local"}, - {"first\\ last@_key.domain.com", "first last", "_key.domain.com"}, - {"first\\\"last@a.b.c", "first\"last", "a.b.c"}, - {"user\\@internal@myhost.ca", "user@internal", "myhost.ca"}, - {"\"first last@evil\"@top-secret.gov", "first last@evil", "top-secret.gov"}, - {"\"line\nfeed\"@linenoise.co.uk", "line\nfeed", "linenoise.co.uk"}, - {"user+mailbox@host", "user+mailbox", "host"}, - {"customer/department=shipping@host", "customer/department=shipping", "host"}, - {"$A12345@host", "$A12345", "host"}, - {"!def!xyz%abc@host", "!def!xyz%abc", "host"}, - {"_somename@host", "_somename", "host"}, - } - - for _, tt := range testTable { - local, domain, err := ParseEmailAddress(tt.input) - if err != nil { - t.Errorf("Error when parsing %q: %s", tt.input, err) - } else { - if tt.local != local { - t.Errorf("When parsing %q, expected local %q, got %q instead", - tt.input, tt.local, local) - } - if tt.domain != domain { - t.Errorf("When parsing %q, expected domain %q, got %q instead", - tt.input, tt.domain, domain) - } - } - } - - // Check that validations fail correctly - var badTable = []struct { - input, msg string - }{ - {"", "Empty address not permitted"}, - {"user", "Missing domain part"}, - {"@host", "Missing local part"}, - {"user\\@host", "Missing domain part"}, - {"\"user@host\"", "Missing domain part"}, - {"\"user@host", "Unterminated quoted string"}, - {"first last@host", "Unquoted space"}, - {"user@bad!domain", "Invalid domain"}, - {".user@host", "Can't lead with a ."}, - {"user.@host", "Can't end local with a dot"}, - {"user@bad domain", "No spaces in domain permitted"}, - } - - for _, tt := range badTable { - if _, _, err := ParseEmailAddress(tt.input); err == nil { - t.Errorf("Did not get expected error when parsing %q: %s", tt.input, tt.msg) - } - } -} diff --git a/themes/greeting.html b/ui/greeting.html similarity index 100% rename from themes/greeting.html rename to ui/greeting.html diff --git a/themes/bootstrap/public/bower.json b/ui/static/bower.json similarity index 100% rename from themes/bootstrap/public/bower.json rename to ui/static/bower.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/.bower.json b/ui/static/bower_components/bootstrap/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/.bower.json rename to ui/static/bower_components/bootstrap/.bower.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/CHANGELOG.md b/ui/static/bower_components/bootstrap/CHANGELOG.md similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/CHANGELOG.md rename to ui/static/bower_components/bootstrap/CHANGELOG.md diff --git a/themes/bootstrap/public/bower_components/bootstrap/Gemfile b/ui/static/bower_components/bootstrap/Gemfile similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/Gemfile rename to ui/static/bower_components/bootstrap/Gemfile diff --git a/themes/bootstrap/public/bower_components/bootstrap/Gemfile.lock b/ui/static/bower_components/bootstrap/Gemfile.lock similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/Gemfile.lock rename to ui/static/bower_components/bootstrap/Gemfile.lock diff --git a/themes/bootstrap/public/bower_components/bootstrap/Gruntfile.js b/ui/static/bower_components/bootstrap/Gruntfile.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/Gruntfile.js rename to ui/static/bower_components/bootstrap/Gruntfile.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/ISSUE_TEMPLATE.md b/ui/static/bower_components/bootstrap/ISSUE_TEMPLATE.md similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/ISSUE_TEMPLATE.md rename to ui/static/bower_components/bootstrap/ISSUE_TEMPLATE.md diff --git a/themes/bootstrap/public/bower_components/bootstrap/LICENSE b/ui/static/bower_components/bootstrap/LICENSE similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/LICENSE rename to ui/static/bower_components/bootstrap/LICENSE diff --git a/themes/bootstrap/public/bower_components/bootstrap/README.md b/ui/static/bower_components/bootstrap/README.md similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/README.md rename to ui/static/bower_components/bootstrap/README.md diff --git a/themes/bootstrap/public/bower_components/bootstrap/bower.json b/ui/static/bower_components/bootstrap/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/bower.json rename to ui/static/bower_components/bootstrap/bower.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.css b/ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.css similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.css rename to ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.css diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.css.map b/ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.css.map similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.css.map rename to ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.css.map diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.min.css b/ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.min.css similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.min.css rename to ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.min.css diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.min.css.map b/ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.min.css.map similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap-theme.min.css.map rename to ui/static/bower_components/bootstrap/dist/css/bootstrap-theme.min.css.map diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.css b/ui/static/bower_components/bootstrap/dist/css/bootstrap.css similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.css rename to ui/static/bower_components/bootstrap/dist/css/bootstrap.css diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.css.map b/ui/static/bower_components/bootstrap/dist/css/bootstrap.css.map similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.css.map rename to ui/static/bower_components/bootstrap/dist/css/bootstrap.css.map diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.min.css b/ui/static/bower_components/bootstrap/dist/css/bootstrap.min.css similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.min.css rename to ui/static/bower_components/bootstrap/dist/css/bootstrap.min.css diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.min.css.map b/ui/static/bower_components/bootstrap/dist/css/bootstrap.min.css.map similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/css/bootstrap.min.css.map rename to ui/static/bower_components/bootstrap/dist/css/bootstrap.min.css.map diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot b/ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot rename to ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg b/ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg rename to ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf b/ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf rename to ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff b/ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff rename to ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 b/ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 rename to ui/static/bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/js/bootstrap.js b/ui/static/bower_components/bootstrap/dist/js/bootstrap.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/js/bootstrap.js rename to ui/static/bower_components/bootstrap/dist/js/bootstrap.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/js/bootstrap.min.js b/ui/static/bower_components/bootstrap/dist/js/bootstrap.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/js/bootstrap.min.js rename to ui/static/bower_components/bootstrap/dist/js/bootstrap.min.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/dist/js/npm.js b/ui/static/bower_components/bootstrap/dist/js/npm.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/dist/js/npm.js rename to ui/static/bower_components/bootstrap/dist/js/npm.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot b/ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot rename to ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot diff --git a/themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg b/ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg rename to ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg diff --git a/themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf b/ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf rename to ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf diff --git a/themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff b/ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff rename to ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff diff --git a/themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff2 b/ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff2 rename to ui/static/bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff2 diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/.jshintrc b/ui/static/bower_components/bootstrap/grunt/.jshintrc similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/.jshintrc rename to ui/static/bower_components/bootstrap/grunt/.jshintrc diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/bs-commonjs-generator.js b/ui/static/bower_components/bootstrap/grunt/bs-commonjs-generator.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/bs-commonjs-generator.js rename to ui/static/bower_components/bootstrap/grunt/bs-commonjs-generator.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/bs-glyphicons-data-generator.js b/ui/static/bower_components/bootstrap/grunt/bs-glyphicons-data-generator.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/bs-glyphicons-data-generator.js rename to ui/static/bower_components/bootstrap/grunt/bs-glyphicons-data-generator.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/bs-lessdoc-parser.js b/ui/static/bower_components/bootstrap/grunt/bs-lessdoc-parser.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/bs-lessdoc-parser.js rename to ui/static/bower_components/bootstrap/grunt/bs-lessdoc-parser.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/bs-raw-files-generator.js b/ui/static/bower_components/bootstrap/grunt/bs-raw-files-generator.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/bs-raw-files-generator.js rename to ui/static/bower_components/bootstrap/grunt/bs-raw-files-generator.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/change-version.js b/ui/static/bower_components/bootstrap/grunt/change-version.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/change-version.js rename to ui/static/bower_components/bootstrap/grunt/change-version.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/configBridge.json b/ui/static/bower_components/bootstrap/grunt/configBridge.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/configBridge.json rename to ui/static/bower_components/bootstrap/grunt/configBridge.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/npm-shrinkwrap.json b/ui/static/bower_components/bootstrap/grunt/npm-shrinkwrap.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/npm-shrinkwrap.json rename to ui/static/bower_components/bootstrap/grunt/npm-shrinkwrap.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/grunt/sauce_browsers.yml b/ui/static/bower_components/bootstrap/grunt/sauce_browsers.yml similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/grunt/sauce_browsers.yml rename to ui/static/bower_components/bootstrap/grunt/sauce_browsers.yml diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/.jscsrc b/ui/static/bower_components/bootstrap/js/.jscsrc similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/.jscsrc rename to ui/static/bower_components/bootstrap/js/.jscsrc diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/.jshintrc b/ui/static/bower_components/bootstrap/js/.jshintrc similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/.jshintrc rename to ui/static/bower_components/bootstrap/js/.jshintrc diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/affix.js b/ui/static/bower_components/bootstrap/js/affix.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/affix.js rename to ui/static/bower_components/bootstrap/js/affix.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/alert.js b/ui/static/bower_components/bootstrap/js/alert.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/alert.js rename to ui/static/bower_components/bootstrap/js/alert.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/button.js b/ui/static/bower_components/bootstrap/js/button.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/button.js rename to ui/static/bower_components/bootstrap/js/button.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/carousel.js b/ui/static/bower_components/bootstrap/js/carousel.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/carousel.js rename to ui/static/bower_components/bootstrap/js/carousel.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/collapse.js b/ui/static/bower_components/bootstrap/js/collapse.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/collapse.js rename to ui/static/bower_components/bootstrap/js/collapse.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/dropdown.js b/ui/static/bower_components/bootstrap/js/dropdown.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/dropdown.js rename to ui/static/bower_components/bootstrap/js/dropdown.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/modal.js b/ui/static/bower_components/bootstrap/js/modal.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/modal.js rename to ui/static/bower_components/bootstrap/js/modal.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/popover.js b/ui/static/bower_components/bootstrap/js/popover.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/popover.js rename to ui/static/bower_components/bootstrap/js/popover.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/scrollspy.js b/ui/static/bower_components/bootstrap/js/scrollspy.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/scrollspy.js rename to ui/static/bower_components/bootstrap/js/scrollspy.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/tab.js b/ui/static/bower_components/bootstrap/js/tab.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/tab.js rename to ui/static/bower_components/bootstrap/js/tab.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/tooltip.js b/ui/static/bower_components/bootstrap/js/tooltip.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/tooltip.js rename to ui/static/bower_components/bootstrap/js/tooltip.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/js/transition.js b/ui/static/bower_components/bootstrap/js/transition.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/js/transition.js rename to ui/static/bower_components/bootstrap/js/transition.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/.csscomb.json b/ui/static/bower_components/bootstrap/less/.csscomb.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/.csscomb.json rename to ui/static/bower_components/bootstrap/less/.csscomb.json diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/.csslintrc b/ui/static/bower_components/bootstrap/less/.csslintrc similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/.csslintrc rename to ui/static/bower_components/bootstrap/less/.csslintrc diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/alerts.less b/ui/static/bower_components/bootstrap/less/alerts.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/alerts.less rename to ui/static/bower_components/bootstrap/less/alerts.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/badges.less b/ui/static/bower_components/bootstrap/less/badges.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/badges.less rename to ui/static/bower_components/bootstrap/less/badges.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/bootstrap.less b/ui/static/bower_components/bootstrap/less/bootstrap.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/bootstrap.less rename to ui/static/bower_components/bootstrap/less/bootstrap.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/breadcrumbs.less b/ui/static/bower_components/bootstrap/less/breadcrumbs.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/breadcrumbs.less rename to ui/static/bower_components/bootstrap/less/breadcrumbs.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/button-groups.less b/ui/static/bower_components/bootstrap/less/button-groups.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/button-groups.less rename to ui/static/bower_components/bootstrap/less/button-groups.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/buttons.less b/ui/static/bower_components/bootstrap/less/buttons.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/buttons.less rename to ui/static/bower_components/bootstrap/less/buttons.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/carousel.less b/ui/static/bower_components/bootstrap/less/carousel.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/carousel.less rename to ui/static/bower_components/bootstrap/less/carousel.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/close.less b/ui/static/bower_components/bootstrap/less/close.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/close.less rename to ui/static/bower_components/bootstrap/less/close.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/code.less b/ui/static/bower_components/bootstrap/less/code.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/code.less rename to ui/static/bower_components/bootstrap/less/code.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/component-animations.less b/ui/static/bower_components/bootstrap/less/component-animations.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/component-animations.less rename to ui/static/bower_components/bootstrap/less/component-animations.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/dropdowns.less b/ui/static/bower_components/bootstrap/less/dropdowns.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/dropdowns.less rename to ui/static/bower_components/bootstrap/less/dropdowns.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/forms.less b/ui/static/bower_components/bootstrap/less/forms.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/forms.less rename to ui/static/bower_components/bootstrap/less/forms.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/glyphicons.less b/ui/static/bower_components/bootstrap/less/glyphicons.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/glyphicons.less rename to ui/static/bower_components/bootstrap/less/glyphicons.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/grid.less b/ui/static/bower_components/bootstrap/less/grid.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/grid.less rename to ui/static/bower_components/bootstrap/less/grid.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/input-groups.less b/ui/static/bower_components/bootstrap/less/input-groups.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/input-groups.less rename to ui/static/bower_components/bootstrap/less/input-groups.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/jumbotron.less b/ui/static/bower_components/bootstrap/less/jumbotron.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/jumbotron.less rename to ui/static/bower_components/bootstrap/less/jumbotron.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/labels.less b/ui/static/bower_components/bootstrap/less/labels.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/labels.less rename to ui/static/bower_components/bootstrap/less/labels.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/list-group.less b/ui/static/bower_components/bootstrap/less/list-group.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/list-group.less rename to ui/static/bower_components/bootstrap/less/list-group.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/media.less b/ui/static/bower_components/bootstrap/less/media.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/media.less rename to ui/static/bower_components/bootstrap/less/media.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins.less b/ui/static/bower_components/bootstrap/less/mixins.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins.less rename to ui/static/bower_components/bootstrap/less/mixins.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/alerts.less b/ui/static/bower_components/bootstrap/less/mixins/alerts.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/alerts.less rename to ui/static/bower_components/bootstrap/less/mixins/alerts.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/background-variant.less b/ui/static/bower_components/bootstrap/less/mixins/background-variant.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/background-variant.less rename to ui/static/bower_components/bootstrap/less/mixins/background-variant.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/border-radius.less b/ui/static/bower_components/bootstrap/less/mixins/border-radius.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/border-radius.less rename to ui/static/bower_components/bootstrap/less/mixins/border-radius.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/buttons.less b/ui/static/bower_components/bootstrap/less/mixins/buttons.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/buttons.less rename to ui/static/bower_components/bootstrap/less/mixins/buttons.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/center-block.less b/ui/static/bower_components/bootstrap/less/mixins/center-block.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/center-block.less rename to ui/static/bower_components/bootstrap/less/mixins/center-block.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/clearfix.less b/ui/static/bower_components/bootstrap/less/mixins/clearfix.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/clearfix.less rename to ui/static/bower_components/bootstrap/less/mixins/clearfix.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/forms.less b/ui/static/bower_components/bootstrap/less/mixins/forms.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/forms.less rename to ui/static/bower_components/bootstrap/less/mixins/forms.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/gradients.less b/ui/static/bower_components/bootstrap/less/mixins/gradients.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/gradients.less rename to ui/static/bower_components/bootstrap/less/mixins/gradients.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/grid-framework.less b/ui/static/bower_components/bootstrap/less/mixins/grid-framework.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/grid-framework.less rename to ui/static/bower_components/bootstrap/less/mixins/grid-framework.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/grid.less b/ui/static/bower_components/bootstrap/less/mixins/grid.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/grid.less rename to ui/static/bower_components/bootstrap/less/mixins/grid.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/hide-text.less b/ui/static/bower_components/bootstrap/less/mixins/hide-text.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/hide-text.less rename to ui/static/bower_components/bootstrap/less/mixins/hide-text.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/image.less b/ui/static/bower_components/bootstrap/less/mixins/image.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/image.less rename to ui/static/bower_components/bootstrap/less/mixins/image.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/labels.less b/ui/static/bower_components/bootstrap/less/mixins/labels.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/labels.less rename to ui/static/bower_components/bootstrap/less/mixins/labels.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/list-group.less b/ui/static/bower_components/bootstrap/less/mixins/list-group.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/list-group.less rename to ui/static/bower_components/bootstrap/less/mixins/list-group.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/nav-divider.less b/ui/static/bower_components/bootstrap/less/mixins/nav-divider.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/nav-divider.less rename to ui/static/bower_components/bootstrap/less/mixins/nav-divider.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/nav-vertical-align.less b/ui/static/bower_components/bootstrap/less/mixins/nav-vertical-align.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/nav-vertical-align.less rename to ui/static/bower_components/bootstrap/less/mixins/nav-vertical-align.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/opacity.less b/ui/static/bower_components/bootstrap/less/mixins/opacity.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/opacity.less rename to ui/static/bower_components/bootstrap/less/mixins/opacity.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/pagination.less b/ui/static/bower_components/bootstrap/less/mixins/pagination.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/pagination.less rename to ui/static/bower_components/bootstrap/less/mixins/pagination.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/panels.less b/ui/static/bower_components/bootstrap/less/mixins/panels.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/panels.less rename to ui/static/bower_components/bootstrap/less/mixins/panels.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/progress-bar.less b/ui/static/bower_components/bootstrap/less/mixins/progress-bar.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/progress-bar.less rename to ui/static/bower_components/bootstrap/less/mixins/progress-bar.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/reset-filter.less b/ui/static/bower_components/bootstrap/less/mixins/reset-filter.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/reset-filter.less rename to ui/static/bower_components/bootstrap/less/mixins/reset-filter.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/reset-text.less b/ui/static/bower_components/bootstrap/less/mixins/reset-text.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/reset-text.less rename to ui/static/bower_components/bootstrap/less/mixins/reset-text.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/resize.less b/ui/static/bower_components/bootstrap/less/mixins/resize.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/resize.less rename to ui/static/bower_components/bootstrap/less/mixins/resize.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/responsive-visibility.less b/ui/static/bower_components/bootstrap/less/mixins/responsive-visibility.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/responsive-visibility.less rename to ui/static/bower_components/bootstrap/less/mixins/responsive-visibility.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/size.less b/ui/static/bower_components/bootstrap/less/mixins/size.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/size.less rename to ui/static/bower_components/bootstrap/less/mixins/size.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/tab-focus.less b/ui/static/bower_components/bootstrap/less/mixins/tab-focus.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/tab-focus.less rename to ui/static/bower_components/bootstrap/less/mixins/tab-focus.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/table-row.less b/ui/static/bower_components/bootstrap/less/mixins/table-row.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/table-row.less rename to ui/static/bower_components/bootstrap/less/mixins/table-row.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/text-emphasis.less b/ui/static/bower_components/bootstrap/less/mixins/text-emphasis.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/text-emphasis.less rename to ui/static/bower_components/bootstrap/less/mixins/text-emphasis.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/text-overflow.less b/ui/static/bower_components/bootstrap/less/mixins/text-overflow.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/text-overflow.less rename to ui/static/bower_components/bootstrap/less/mixins/text-overflow.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/mixins/vendor-prefixes.less b/ui/static/bower_components/bootstrap/less/mixins/vendor-prefixes.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/mixins/vendor-prefixes.less rename to ui/static/bower_components/bootstrap/less/mixins/vendor-prefixes.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/modals.less b/ui/static/bower_components/bootstrap/less/modals.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/modals.less rename to ui/static/bower_components/bootstrap/less/modals.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/navbar.less b/ui/static/bower_components/bootstrap/less/navbar.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/navbar.less rename to ui/static/bower_components/bootstrap/less/navbar.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/navs.less b/ui/static/bower_components/bootstrap/less/navs.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/navs.less rename to ui/static/bower_components/bootstrap/less/navs.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/normalize.less b/ui/static/bower_components/bootstrap/less/normalize.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/normalize.less rename to ui/static/bower_components/bootstrap/less/normalize.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/pager.less b/ui/static/bower_components/bootstrap/less/pager.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/pager.less rename to ui/static/bower_components/bootstrap/less/pager.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/pagination.less b/ui/static/bower_components/bootstrap/less/pagination.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/pagination.less rename to ui/static/bower_components/bootstrap/less/pagination.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/panels.less b/ui/static/bower_components/bootstrap/less/panels.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/panels.less rename to ui/static/bower_components/bootstrap/less/panels.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/popovers.less b/ui/static/bower_components/bootstrap/less/popovers.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/popovers.less rename to ui/static/bower_components/bootstrap/less/popovers.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/print.less b/ui/static/bower_components/bootstrap/less/print.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/print.less rename to ui/static/bower_components/bootstrap/less/print.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/progress-bars.less b/ui/static/bower_components/bootstrap/less/progress-bars.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/progress-bars.less rename to ui/static/bower_components/bootstrap/less/progress-bars.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/responsive-embed.less b/ui/static/bower_components/bootstrap/less/responsive-embed.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/responsive-embed.less rename to ui/static/bower_components/bootstrap/less/responsive-embed.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/responsive-utilities.less b/ui/static/bower_components/bootstrap/less/responsive-utilities.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/responsive-utilities.less rename to ui/static/bower_components/bootstrap/less/responsive-utilities.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/scaffolding.less b/ui/static/bower_components/bootstrap/less/scaffolding.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/scaffolding.less rename to ui/static/bower_components/bootstrap/less/scaffolding.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/tables.less b/ui/static/bower_components/bootstrap/less/tables.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/tables.less rename to ui/static/bower_components/bootstrap/less/tables.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/theme.less b/ui/static/bower_components/bootstrap/less/theme.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/theme.less rename to ui/static/bower_components/bootstrap/less/theme.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/thumbnails.less b/ui/static/bower_components/bootstrap/less/thumbnails.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/thumbnails.less rename to ui/static/bower_components/bootstrap/less/thumbnails.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/tooltip.less b/ui/static/bower_components/bootstrap/less/tooltip.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/tooltip.less rename to ui/static/bower_components/bootstrap/less/tooltip.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/type.less b/ui/static/bower_components/bootstrap/less/type.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/type.less rename to ui/static/bower_components/bootstrap/less/type.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/utilities.less b/ui/static/bower_components/bootstrap/less/utilities.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/utilities.less rename to ui/static/bower_components/bootstrap/less/utilities.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/variables.less b/ui/static/bower_components/bootstrap/less/variables.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/variables.less rename to ui/static/bower_components/bootstrap/less/variables.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/less/wells.less b/ui/static/bower_components/bootstrap/less/wells.less similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/less/wells.less rename to ui/static/bower_components/bootstrap/less/wells.less diff --git a/themes/bootstrap/public/bower_components/bootstrap/nuget/MyGet.ps1 b/ui/static/bower_components/bootstrap/nuget/MyGet.ps1 similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/nuget/MyGet.ps1 rename to ui/static/bower_components/bootstrap/nuget/MyGet.ps1 diff --git a/themes/bootstrap/public/bower_components/bootstrap/nuget/bootstrap.less.nuspec b/ui/static/bower_components/bootstrap/nuget/bootstrap.less.nuspec similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/nuget/bootstrap.less.nuspec rename to ui/static/bower_components/bootstrap/nuget/bootstrap.less.nuspec diff --git a/themes/bootstrap/public/bower_components/bootstrap/nuget/bootstrap.nuspec b/ui/static/bower_components/bootstrap/nuget/bootstrap.nuspec similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/nuget/bootstrap.nuspec rename to ui/static/bower_components/bootstrap/nuget/bootstrap.nuspec diff --git a/themes/bootstrap/public/bower_components/bootstrap/package.js b/ui/static/bower_components/bootstrap/package.js similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/package.js rename to ui/static/bower_components/bootstrap/package.js diff --git a/themes/bootstrap/public/bower_components/bootstrap/package.json b/ui/static/bower_components/bootstrap/package.json similarity index 100% rename from themes/bootstrap/public/bower_components/bootstrap/package.json rename to ui/static/bower_components/bootstrap/package.json diff --git a/themes/bootstrap/public/bower_components/clipboard/.bower.json b/ui/static/bower_components/clipboard/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/.bower.json rename to ui/static/bower_components/clipboard/.bower.json diff --git a/themes/bootstrap/public/bower_components/clipboard/bower.json b/ui/static/bower_components/clipboard/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/bower.json rename to ui/static/bower_components/clipboard/bower.json diff --git a/themes/bootstrap/public/bower_components/clipboard/contributing.md b/ui/static/bower_components/clipboard/contributing.md similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/contributing.md rename to ui/static/bower_components/clipboard/contributing.md diff --git a/themes/bootstrap/public/bower_components/clipboard/dist/clipboard.js b/ui/static/bower_components/clipboard/dist/clipboard.js similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/dist/clipboard.js rename to ui/static/bower_components/clipboard/dist/clipboard.js diff --git a/themes/bootstrap/public/bower_components/clipboard/dist/clipboard.min.js b/ui/static/bower_components/clipboard/dist/clipboard.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/dist/clipboard.min.js rename to ui/static/bower_components/clipboard/dist/clipboard.min.js diff --git a/themes/bootstrap/public/bower_components/clipboard/package.js b/ui/static/bower_components/clipboard/package.js similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/package.js rename to ui/static/bower_components/clipboard/package.js diff --git a/themes/bootstrap/public/bower_components/clipboard/package.json b/ui/static/bower_components/clipboard/package.json similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/package.json rename to ui/static/bower_components/clipboard/package.json diff --git a/themes/bootstrap/public/bower_components/clipboard/readme.md b/ui/static/bower_components/clipboard/readme.md similarity index 100% rename from themes/bootstrap/public/bower_components/clipboard/readme.md rename to ui/static/bower_components/clipboard/readme.md diff --git a/themes/bootstrap/public/bower_components/jquery-color/.bower.json b/ui/static/bower_components/jquery-color/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/.bower.json rename to ui/static/bower_components/jquery-color/.bower.json diff --git a/themes/bootstrap/public/bower_components/jquery-color/.gitignore b/ui/static/bower_components/jquery-color/.gitignore similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/.gitignore rename to ui/static/bower_components/jquery-color/.gitignore diff --git a/themes/bootstrap/public/bower_components/jquery-color/.gitmodules b/ui/static/bower_components/jquery-color/.gitmodules similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/.gitmodules rename to ui/static/bower_components/jquery-color/.gitmodules diff --git a/themes/bootstrap/public/bower_components/jquery-color/.jshintrc b/ui/static/bower_components/jquery-color/.jshintrc similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/.jshintrc rename to ui/static/bower_components/jquery-color/.jshintrc diff --git a/themes/bootstrap/public/bower_components/jquery-color/AUTHORS.TXT b/ui/static/bower_components/jquery-color/AUTHORS.TXT similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/AUTHORS.TXT rename to ui/static/bower_components/jquery-color/AUTHORS.TXT diff --git a/themes/bootstrap/public/bower_components/jquery-color/MIT-LICENSE.txt b/ui/static/bower_components/jquery-color/MIT-LICENSE.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/MIT-LICENSE.txt rename to ui/static/bower_components/jquery-color/MIT-LICENSE.txt diff --git a/themes/bootstrap/public/bower_components/jquery-color/README.md b/ui/static/bower_components/jquery-color/README.md similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/README.md rename to ui/static/bower_components/jquery-color/README.md diff --git a/themes/bootstrap/public/bower_components/jquery-color/color.jquery.json b/ui/static/bower_components/jquery-color/color.jquery.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/color.jquery.json rename to ui/static/bower_components/jquery-color/color.jquery.json diff --git a/themes/bootstrap/public/bower_components/jquery-color/grunt.js b/ui/static/bower_components/jquery-color/grunt.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/grunt.js rename to ui/static/bower_components/jquery-color/grunt.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/jquery.color.js b/ui/static/bower_components/jquery-color/jquery.color.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/jquery.color.js rename to ui/static/bower_components/jquery-color/jquery.color.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/jquery.color.svg-names.js b/ui/static/bower_components/jquery-color/jquery.color.svg-names.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/jquery.color.svg-names.js rename to ui/static/bower_components/jquery-color/jquery.color.svg-names.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/package.json b/ui/static/bower_components/jquery-color/package.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/package.json rename to ui/static/bower_components/jquery-color/package.json diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/data/swarminject.js b/ui/static/bower_components/jquery-color/test/data/swarminject.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/data/swarminject.js rename to ui/static/bower_components/jquery-color/test/data/swarminject.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/data/testinit.js b/ui/static/bower_components/jquery-color/test/data/testinit.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/data/testinit.js rename to ui/static/bower_components/jquery-color/test/data/testinit.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/index.html b/ui/static/bower_components/jquery-color/test/index.html similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/index.html rename to ui/static/bower_components/jquery-color/test/index.html diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.1.js b/ui/static/bower_components/jquery-color/test/jquery-1.5.1.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.1.js rename to ui/static/bower_components/jquery-color/test/jquery-1.5.1.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.2.js b/ui/static/bower_components/jquery-color/test/jquery-1.5.2.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.2.js rename to ui/static/bower_components/jquery-color/test/jquery-1.5.2.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.js b/ui/static/bower_components/jquery-color/test/jquery-1.5.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.5.js rename to ui/static/bower_components/jquery-color/test/jquery-1.5.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.1.js b/ui/static/bower_components/jquery-color/test/jquery-1.6.1.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.1.js rename to ui/static/bower_components/jquery-color/test/jquery-1.6.1.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.2.js b/ui/static/bower_components/jquery-color/test/jquery-1.6.2.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.2.js rename to ui/static/bower_components/jquery-color/test/jquery-1.6.2.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.3.js b/ui/static/bower_components/jquery-color/test/jquery-1.6.3.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.3.js rename to ui/static/bower_components/jquery-color/test/jquery-1.6.3.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.4.js b/ui/static/bower_components/jquery-color/test/jquery-1.6.4.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.4.js rename to ui/static/bower_components/jquery-color/test/jquery-1.6.4.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.js b/ui/static/bower_components/jquery-color/test/jquery-1.6.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.6.js rename to ui/static/bower_components/jquery-color/test/jquery-1.6.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.1.js b/ui/static/bower_components/jquery-color/test/jquery-1.7.1.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.1.js rename to ui/static/bower_components/jquery-color/test/jquery-1.7.1.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.2.js b/ui/static/bower_components/jquery-color/test/jquery-1.7.2.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.2.js rename to ui/static/bower_components/jquery-color/test/jquery-1.7.2.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.js b/ui/static/bower_components/jquery-color/test/jquery-1.7.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery-1.7.js rename to ui/static/bower_components/jquery-color/test/jquery-1.7.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/jquery.js b/ui/static/bower_components/jquery-color/test/jquery.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/jquery.js rename to ui/static/bower_components/jquery-color/test/jquery.js diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/test.html b/ui/static/bower_components/jquery-color/test/test.html similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/test.html rename to ui/static/bower_components/jquery-color/test/test.html diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/unit/.jshintrc b/ui/static/bower_components/jquery-color/test/unit/.jshintrc similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/unit/.jshintrc rename to ui/static/bower_components/jquery-color/test/unit/.jshintrc diff --git a/themes/bootstrap/public/bower_components/jquery-color/test/unit/color.js b/ui/static/bower_components/jquery-color/test/unit/color.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-color/test/unit/color.js rename to ui/static/bower_components/jquery-color/test/unit/color.js diff --git a/themes/bootstrap/public/bower_components/jquery-load-template/.bower.json b/ui/static/bower_components/jquery-load-template/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-load-template/.bower.json rename to ui/static/bower_components/jquery-load-template/.bower.json diff --git a/themes/bootstrap/public/bower_components/jquery-load-template/bower.json b/ui/static/bower_components/jquery-load-template/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-load-template/bower.json rename to ui/static/bower_components/jquery-load-template/bower.json diff --git a/themes/bootstrap/public/bower_components/jquery-load-template/dist/jquery.loadTemplate.js b/ui/static/bower_components/jquery-load-template/dist/jquery.loadTemplate.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-load-template/dist/jquery.loadTemplate.js rename to ui/static/bower_components/jquery-load-template/dist/jquery.loadTemplate.js diff --git a/themes/bootstrap/public/bower_components/jquery-load-template/dist/jquery.loadTemplate.min.js b/ui/static/bower_components/jquery-load-template/dist/jquery.loadTemplate.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-load-template/dist/jquery.loadTemplate.min.js rename to ui/static/bower_components/jquery-load-template/dist/jquery.loadTemplate.min.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/.bower.json b/ui/static/bower_components/jquery-sparkline/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/.bower.json rename to ui/static/bower_components/jquery-sparkline/.bower.json diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/Changelog.txt b/ui/static/bower_components/jquery-sparkline/Changelog.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/Changelog.txt rename to ui/static/bower_components/jquery-sparkline/Changelog.txt diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/Makefile b/ui/static/bower_components/jquery-sparkline/Makefile similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/Makefile rename to ui/static/bower_components/jquery-sparkline/Makefile diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/README.md b/ui/static/bower_components/jquery-sparkline/README.md similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/README.md rename to ui/static/bower_components/jquery-sparkline/README.md diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/bower.json b/ui/static/bower_components/jquery-sparkline/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/bower.json rename to ui/static/bower_components/jquery-sparkline/bower.json diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/dist/jquery.sparkline.js b/ui/static/bower_components/jquery-sparkline/dist/jquery.sparkline.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/dist/jquery.sparkline.js rename to ui/static/bower_components/jquery-sparkline/dist/jquery.sparkline.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/dist/jquery.sparkline.min.js b/ui/static/bower_components/jquery-sparkline/dist/jquery.sparkline.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/dist/jquery.sparkline.min.js rename to ui/static/bower_components/jquery-sparkline/dist/jquery.sparkline.min.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/minheader.txt b/ui/static/bower_components/jquery-sparkline/minheader.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/minheader.txt rename to ui/static/bower_components/jquery-sparkline/minheader.txt diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/sparkline.jquery.json b/ui/static/bower_components/jquery-sparkline/sparkline.jquery.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/sparkline.jquery.json rename to ui/static/bower_components/jquery-sparkline/sparkline.jquery.json diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/base.js b/ui/static/bower_components/jquery-sparkline/src/base.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/base.js rename to ui/static/bower_components/jquery-sparkline/src/base.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-bar.js b/ui/static/bower_components/jquery-sparkline/src/chart-bar.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-bar.js rename to ui/static/bower_components/jquery-sparkline/src/chart-bar.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-box.js b/ui/static/bower_components/jquery-sparkline/src/chart-box.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-box.js rename to ui/static/bower_components/jquery-sparkline/src/chart-box.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-bullet.js b/ui/static/bower_components/jquery-sparkline/src/chart-bullet.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-bullet.js rename to ui/static/bower_components/jquery-sparkline/src/chart-bullet.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-discrete.js b/ui/static/bower_components/jquery-sparkline/src/chart-discrete.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-discrete.js rename to ui/static/bower_components/jquery-sparkline/src/chart-discrete.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-line.js b/ui/static/bower_components/jquery-sparkline/src/chart-line.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-line.js rename to ui/static/bower_components/jquery-sparkline/src/chart-line.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-pie.js b/ui/static/bower_components/jquery-sparkline/src/chart-pie.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-pie.js rename to ui/static/bower_components/jquery-sparkline/src/chart-pie.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-tristate.js b/ui/static/bower_components/jquery-sparkline/src/chart-tristate.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/chart-tristate.js rename to ui/static/bower_components/jquery-sparkline/src/chart-tristate.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/defaults.js b/ui/static/bower_components/jquery-sparkline/src/defaults.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/defaults.js rename to ui/static/bower_components/jquery-sparkline/src/defaults.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/footer.js b/ui/static/bower_components/jquery-sparkline/src/footer.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/footer.js rename to ui/static/bower_components/jquery-sparkline/src/footer.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/header.js b/ui/static/bower_components/jquery-sparkline/src/header.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/header.js rename to ui/static/bower_components/jquery-sparkline/src/header.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/interact.js b/ui/static/bower_components/jquery-sparkline/src/interact.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/interact.js rename to ui/static/bower_components/jquery-sparkline/src/interact.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/rangemap.js b/ui/static/bower_components/jquery-sparkline/src/rangemap.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/rangemap.js rename to ui/static/bower_components/jquery-sparkline/src/rangemap.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/simpledraw.js b/ui/static/bower_components/jquery-sparkline/src/simpledraw.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/simpledraw.js rename to ui/static/bower_components/jquery-sparkline/src/simpledraw.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/utils.js b/ui/static/bower_components/jquery-sparkline/src/utils.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/utils.js rename to ui/static/bower_components/jquery-sparkline/src/utils.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-base.js b/ui/static/bower_components/jquery-sparkline/src/vcanvas-base.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-base.js rename to ui/static/bower_components/jquery-sparkline/src/vcanvas-base.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-canvas.js b/ui/static/bower_components/jquery-sparkline/src/vcanvas-canvas.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-canvas.js rename to ui/static/bower_components/jquery-sparkline/src/vcanvas-canvas.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-vml.js b/ui/static/bower_components/jquery-sparkline/src/vcanvas-vml.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/src/vcanvas-vml.js rename to ui/static/bower_components/jquery-sparkline/src/vcanvas-vml.js diff --git a/themes/bootstrap/public/bower_components/jquery-sparkline/version.txt b/ui/static/bower_components/jquery-sparkline/version.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery-sparkline/version.txt rename to ui/static/bower_components/jquery-sparkline/version.txt diff --git a/themes/bootstrap/public/bower_components/jquery/.bower.json b/ui/static/bower_components/jquery/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/.bower.json rename to ui/static/bower_components/jquery/.bower.json diff --git a/themes/bootstrap/public/bower_components/jquery/AUTHORS.txt b/ui/static/bower_components/jquery/AUTHORS.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/AUTHORS.txt rename to ui/static/bower_components/jquery/AUTHORS.txt diff --git a/themes/bootstrap/public/bower_components/jquery/LICENSE.txt b/ui/static/bower_components/jquery/LICENSE.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/LICENSE.txt rename to ui/static/bower_components/jquery/LICENSE.txt diff --git a/themes/bootstrap/public/bower_components/jquery/README.md b/ui/static/bower_components/jquery/README.md similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/README.md rename to ui/static/bower_components/jquery/README.md diff --git a/themes/bootstrap/public/bower_components/jquery/bower.json b/ui/static/bower_components/jquery/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/bower.json rename to ui/static/bower_components/jquery/bower.json diff --git a/themes/bootstrap/public/bower_components/jquery/dist/jquery.js b/ui/static/bower_components/jquery/dist/jquery.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/dist/jquery.js rename to ui/static/bower_components/jquery/dist/jquery.js diff --git a/themes/bootstrap/public/bower_components/jquery/dist/jquery.min.js b/ui/static/bower_components/jquery/dist/jquery.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/dist/jquery.min.js rename to ui/static/bower_components/jquery/dist/jquery.min.js diff --git a/themes/bootstrap/public/bower_components/jquery/dist/jquery.min.map b/ui/static/bower_components/jquery/dist/jquery.min.map similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/dist/jquery.min.map rename to ui/static/bower_components/jquery/dist/jquery.min.map diff --git a/themes/bootstrap/public/bower_components/jquery/external/sizzle/LICENSE.txt b/ui/static/bower_components/jquery/external/sizzle/LICENSE.txt similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/external/sizzle/LICENSE.txt rename to ui/static/bower_components/jquery/external/sizzle/LICENSE.txt diff --git a/themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.js b/ui/static/bower_components/jquery/external/sizzle/dist/sizzle.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.js rename to ui/static/bower_components/jquery/external/sizzle/dist/sizzle.js diff --git a/themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.min.js b/ui/static/bower_components/jquery/external/sizzle/dist/sizzle.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.min.js rename to ui/static/bower_components/jquery/external/sizzle/dist/sizzle.min.js diff --git a/themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.min.map b/ui/static/bower_components/jquery/external/sizzle/dist/sizzle.min.map similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/external/sizzle/dist/sizzle.min.map rename to ui/static/bower_components/jquery/external/sizzle/dist/sizzle.min.map diff --git a/themes/bootstrap/public/bower_components/jquery/src/.jshintrc b/ui/static/bower_components/jquery/src/.jshintrc similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/.jshintrc rename to ui/static/bower_components/jquery/src/.jshintrc diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax.js b/ui/static/bower_components/jquery/src/ajax.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax.js rename to ui/static/bower_components/jquery/src/ajax.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/jsonp.js b/ui/static/bower_components/jquery/src/ajax/jsonp.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/jsonp.js rename to ui/static/bower_components/jquery/src/ajax/jsonp.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/load.js b/ui/static/bower_components/jquery/src/ajax/load.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/load.js rename to ui/static/bower_components/jquery/src/ajax/load.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/parseJSON.js b/ui/static/bower_components/jquery/src/ajax/parseJSON.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/parseJSON.js rename to ui/static/bower_components/jquery/src/ajax/parseJSON.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/parseXML.js b/ui/static/bower_components/jquery/src/ajax/parseXML.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/parseXML.js rename to ui/static/bower_components/jquery/src/ajax/parseXML.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/script.js b/ui/static/bower_components/jquery/src/ajax/script.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/script.js rename to ui/static/bower_components/jquery/src/ajax/script.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/var/location.js b/ui/static/bower_components/jquery/src/ajax/var/location.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/var/location.js rename to ui/static/bower_components/jquery/src/ajax/var/location.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/var/nonce.js b/ui/static/bower_components/jquery/src/ajax/var/nonce.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/var/nonce.js rename to ui/static/bower_components/jquery/src/ajax/var/nonce.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/var/rquery.js b/ui/static/bower_components/jquery/src/ajax/var/rquery.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/var/rquery.js rename to ui/static/bower_components/jquery/src/ajax/var/rquery.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/ajax/xhr.js b/ui/static/bower_components/jquery/src/ajax/xhr.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/ajax/xhr.js rename to ui/static/bower_components/jquery/src/ajax/xhr.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes.js b/ui/static/bower_components/jquery/src/attributes.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes.js rename to ui/static/bower_components/jquery/src/attributes.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes/attr.js b/ui/static/bower_components/jquery/src/attributes/attr.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes/attr.js rename to ui/static/bower_components/jquery/src/attributes/attr.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes/classes.js b/ui/static/bower_components/jquery/src/attributes/classes.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes/classes.js rename to ui/static/bower_components/jquery/src/attributes/classes.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes/prop.js b/ui/static/bower_components/jquery/src/attributes/prop.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes/prop.js rename to ui/static/bower_components/jquery/src/attributes/prop.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes/support.js b/ui/static/bower_components/jquery/src/attributes/support.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes/support.js rename to ui/static/bower_components/jquery/src/attributes/support.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/attributes/val.js b/ui/static/bower_components/jquery/src/attributes/val.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/attributes/val.js rename to ui/static/bower_components/jquery/src/attributes/val.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/callbacks.js b/ui/static/bower_components/jquery/src/callbacks.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/callbacks.js rename to ui/static/bower_components/jquery/src/callbacks.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core.js b/ui/static/bower_components/jquery/src/core.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core.js rename to ui/static/bower_components/jquery/src/core.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core/access.js b/ui/static/bower_components/jquery/src/core/access.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core/access.js rename to ui/static/bower_components/jquery/src/core/access.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core/init.js b/ui/static/bower_components/jquery/src/core/init.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core/init.js rename to ui/static/bower_components/jquery/src/core/init.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core/parseHTML.js b/ui/static/bower_components/jquery/src/core/parseHTML.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core/parseHTML.js rename to ui/static/bower_components/jquery/src/core/parseHTML.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core/ready.js b/ui/static/bower_components/jquery/src/core/ready.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core/ready.js rename to ui/static/bower_components/jquery/src/core/ready.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/core/var/rsingleTag.js b/ui/static/bower_components/jquery/src/core/var/rsingleTag.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/core/var/rsingleTag.js rename to ui/static/bower_components/jquery/src/core/var/rsingleTag.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css.js b/ui/static/bower_components/jquery/src/css.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css.js rename to ui/static/bower_components/jquery/src/css.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/addGetHookIf.js b/ui/static/bower_components/jquery/src/css/addGetHookIf.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/addGetHookIf.js rename to ui/static/bower_components/jquery/src/css/addGetHookIf.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/adjustCSS.js b/ui/static/bower_components/jquery/src/css/adjustCSS.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/adjustCSS.js rename to ui/static/bower_components/jquery/src/css/adjustCSS.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/curCSS.js b/ui/static/bower_components/jquery/src/css/curCSS.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/curCSS.js rename to ui/static/bower_components/jquery/src/css/curCSS.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/defaultDisplay.js b/ui/static/bower_components/jquery/src/css/defaultDisplay.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/defaultDisplay.js rename to ui/static/bower_components/jquery/src/css/defaultDisplay.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/hiddenVisibleSelectors.js b/ui/static/bower_components/jquery/src/css/hiddenVisibleSelectors.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/hiddenVisibleSelectors.js rename to ui/static/bower_components/jquery/src/css/hiddenVisibleSelectors.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/showHide.js b/ui/static/bower_components/jquery/src/css/showHide.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/showHide.js rename to ui/static/bower_components/jquery/src/css/showHide.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/support.js b/ui/static/bower_components/jquery/src/css/support.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/support.js rename to ui/static/bower_components/jquery/src/css/support.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/cssExpand.js b/ui/static/bower_components/jquery/src/css/var/cssExpand.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/cssExpand.js rename to ui/static/bower_components/jquery/src/css/var/cssExpand.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/getStyles.js b/ui/static/bower_components/jquery/src/css/var/getStyles.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/getStyles.js rename to ui/static/bower_components/jquery/src/css/var/getStyles.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/isHidden.js b/ui/static/bower_components/jquery/src/css/var/isHidden.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/isHidden.js rename to ui/static/bower_components/jquery/src/css/var/isHidden.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/rmargin.js b/ui/static/bower_components/jquery/src/css/var/rmargin.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/rmargin.js rename to ui/static/bower_components/jquery/src/css/var/rmargin.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/rnumnonpx.js b/ui/static/bower_components/jquery/src/css/var/rnumnonpx.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/rnumnonpx.js rename to ui/static/bower_components/jquery/src/css/var/rnumnonpx.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/css/var/swap.js b/ui/static/bower_components/jquery/src/css/var/swap.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/css/var/swap.js rename to ui/static/bower_components/jquery/src/css/var/swap.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/data.js b/ui/static/bower_components/jquery/src/data.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/data.js rename to ui/static/bower_components/jquery/src/data.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/data/Data.js b/ui/static/bower_components/jquery/src/data/Data.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/data/Data.js rename to ui/static/bower_components/jquery/src/data/Data.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/data/var/acceptData.js b/ui/static/bower_components/jquery/src/data/var/acceptData.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/data/var/acceptData.js rename to ui/static/bower_components/jquery/src/data/var/acceptData.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/data/var/dataPriv.js b/ui/static/bower_components/jquery/src/data/var/dataPriv.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/data/var/dataPriv.js rename to ui/static/bower_components/jquery/src/data/var/dataPriv.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/data/var/dataUser.js b/ui/static/bower_components/jquery/src/data/var/dataUser.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/data/var/dataUser.js rename to ui/static/bower_components/jquery/src/data/var/dataUser.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/deferred.js b/ui/static/bower_components/jquery/src/deferred.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/deferred.js rename to ui/static/bower_components/jquery/src/deferred.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/deprecated.js b/ui/static/bower_components/jquery/src/deprecated.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/deprecated.js rename to ui/static/bower_components/jquery/src/deprecated.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/dimensions.js b/ui/static/bower_components/jquery/src/dimensions.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/dimensions.js rename to ui/static/bower_components/jquery/src/dimensions.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/effects.js b/ui/static/bower_components/jquery/src/effects.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/effects.js rename to ui/static/bower_components/jquery/src/effects.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/effects/Tween.js b/ui/static/bower_components/jquery/src/effects/Tween.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/effects/Tween.js rename to ui/static/bower_components/jquery/src/effects/Tween.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/effects/animatedSelector.js b/ui/static/bower_components/jquery/src/effects/animatedSelector.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/effects/animatedSelector.js rename to ui/static/bower_components/jquery/src/effects/animatedSelector.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event.js b/ui/static/bower_components/jquery/src/event.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event.js rename to ui/static/bower_components/jquery/src/event.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event/ajax.js b/ui/static/bower_components/jquery/src/event/ajax.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event/ajax.js rename to ui/static/bower_components/jquery/src/event/ajax.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event/alias.js b/ui/static/bower_components/jquery/src/event/alias.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event/alias.js rename to ui/static/bower_components/jquery/src/event/alias.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event/focusin.js b/ui/static/bower_components/jquery/src/event/focusin.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event/focusin.js rename to ui/static/bower_components/jquery/src/event/focusin.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event/support.js b/ui/static/bower_components/jquery/src/event/support.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event/support.js rename to ui/static/bower_components/jquery/src/event/support.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/event/trigger.js b/ui/static/bower_components/jquery/src/event/trigger.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/event/trigger.js rename to ui/static/bower_components/jquery/src/event/trigger.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/exports/amd.js b/ui/static/bower_components/jquery/src/exports/amd.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/exports/amd.js rename to ui/static/bower_components/jquery/src/exports/amd.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/exports/global.js b/ui/static/bower_components/jquery/src/exports/global.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/exports/global.js rename to ui/static/bower_components/jquery/src/exports/global.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/intro.js b/ui/static/bower_components/jquery/src/intro.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/intro.js rename to ui/static/bower_components/jquery/src/intro.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/jquery.js b/ui/static/bower_components/jquery/src/jquery.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/jquery.js rename to ui/static/bower_components/jquery/src/jquery.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation.js b/ui/static/bower_components/jquery/src/manipulation.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation.js rename to ui/static/bower_components/jquery/src/manipulation.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/_evalUrl.js b/ui/static/bower_components/jquery/src/manipulation/_evalUrl.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/_evalUrl.js rename to ui/static/bower_components/jquery/src/manipulation/_evalUrl.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/buildFragment.js b/ui/static/bower_components/jquery/src/manipulation/buildFragment.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/buildFragment.js rename to ui/static/bower_components/jquery/src/manipulation/buildFragment.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/getAll.js b/ui/static/bower_components/jquery/src/manipulation/getAll.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/getAll.js rename to ui/static/bower_components/jquery/src/manipulation/getAll.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/setGlobalEval.js b/ui/static/bower_components/jquery/src/manipulation/setGlobalEval.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/setGlobalEval.js rename to ui/static/bower_components/jquery/src/manipulation/setGlobalEval.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/support.js b/ui/static/bower_components/jquery/src/manipulation/support.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/support.js rename to ui/static/bower_components/jquery/src/manipulation/support.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rcheckableType.js b/ui/static/bower_components/jquery/src/manipulation/var/rcheckableType.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rcheckableType.js rename to ui/static/bower_components/jquery/src/manipulation/var/rcheckableType.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rscriptType.js b/ui/static/bower_components/jquery/src/manipulation/var/rscriptType.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rscriptType.js rename to ui/static/bower_components/jquery/src/manipulation/var/rscriptType.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rtagName.js b/ui/static/bower_components/jquery/src/manipulation/var/rtagName.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/var/rtagName.js rename to ui/static/bower_components/jquery/src/manipulation/var/rtagName.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/manipulation/wrapMap.js b/ui/static/bower_components/jquery/src/manipulation/wrapMap.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/manipulation/wrapMap.js rename to ui/static/bower_components/jquery/src/manipulation/wrapMap.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/offset.js b/ui/static/bower_components/jquery/src/offset.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/offset.js rename to ui/static/bower_components/jquery/src/offset.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/outro.js b/ui/static/bower_components/jquery/src/outro.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/outro.js rename to ui/static/bower_components/jquery/src/outro.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/queue.js b/ui/static/bower_components/jquery/src/queue.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/queue.js rename to ui/static/bower_components/jquery/src/queue.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/queue/delay.js b/ui/static/bower_components/jquery/src/queue/delay.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/queue/delay.js rename to ui/static/bower_components/jquery/src/queue/delay.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/selector-native.js b/ui/static/bower_components/jquery/src/selector-native.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/selector-native.js rename to ui/static/bower_components/jquery/src/selector-native.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/selector-sizzle.js b/ui/static/bower_components/jquery/src/selector-sizzle.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/selector-sizzle.js rename to ui/static/bower_components/jquery/src/selector-sizzle.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/selector.js b/ui/static/bower_components/jquery/src/selector.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/selector.js rename to ui/static/bower_components/jquery/src/selector.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/serialize.js b/ui/static/bower_components/jquery/src/serialize.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/serialize.js rename to ui/static/bower_components/jquery/src/serialize.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/traversing.js b/ui/static/bower_components/jquery/src/traversing.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/traversing.js rename to ui/static/bower_components/jquery/src/traversing.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/traversing/findFilter.js b/ui/static/bower_components/jquery/src/traversing/findFilter.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/traversing/findFilter.js rename to ui/static/bower_components/jquery/src/traversing/findFilter.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/traversing/var/dir.js b/ui/static/bower_components/jquery/src/traversing/var/dir.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/traversing/var/dir.js rename to ui/static/bower_components/jquery/src/traversing/var/dir.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/traversing/var/rneedsContext.js b/ui/static/bower_components/jquery/src/traversing/var/rneedsContext.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/traversing/var/rneedsContext.js rename to ui/static/bower_components/jquery/src/traversing/var/rneedsContext.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/traversing/var/siblings.js b/ui/static/bower_components/jquery/src/traversing/var/siblings.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/traversing/var/siblings.js rename to ui/static/bower_components/jquery/src/traversing/var/siblings.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/arr.js b/ui/static/bower_components/jquery/src/var/arr.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/arr.js rename to ui/static/bower_components/jquery/src/var/arr.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/class2type.js b/ui/static/bower_components/jquery/src/var/class2type.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/class2type.js rename to ui/static/bower_components/jquery/src/var/class2type.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/concat.js b/ui/static/bower_components/jquery/src/var/concat.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/concat.js rename to ui/static/bower_components/jquery/src/var/concat.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/document.js b/ui/static/bower_components/jquery/src/var/document.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/document.js rename to ui/static/bower_components/jquery/src/var/document.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/documentElement.js b/ui/static/bower_components/jquery/src/var/documentElement.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/documentElement.js rename to ui/static/bower_components/jquery/src/var/documentElement.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/hasOwn.js b/ui/static/bower_components/jquery/src/var/hasOwn.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/hasOwn.js rename to ui/static/bower_components/jquery/src/var/hasOwn.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/indexOf.js b/ui/static/bower_components/jquery/src/var/indexOf.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/indexOf.js rename to ui/static/bower_components/jquery/src/var/indexOf.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/pnum.js b/ui/static/bower_components/jquery/src/var/pnum.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/pnum.js rename to ui/static/bower_components/jquery/src/var/pnum.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/push.js b/ui/static/bower_components/jquery/src/var/push.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/push.js rename to ui/static/bower_components/jquery/src/var/push.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/rcssNum.js b/ui/static/bower_components/jquery/src/var/rcssNum.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/rcssNum.js rename to ui/static/bower_components/jquery/src/var/rcssNum.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/rnotwhite.js b/ui/static/bower_components/jquery/src/var/rnotwhite.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/rnotwhite.js rename to ui/static/bower_components/jquery/src/var/rnotwhite.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/slice.js b/ui/static/bower_components/jquery/src/var/slice.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/slice.js rename to ui/static/bower_components/jquery/src/var/slice.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/support.js b/ui/static/bower_components/jquery/src/var/support.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/support.js rename to ui/static/bower_components/jquery/src/var/support.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/var/toString.js b/ui/static/bower_components/jquery/src/var/toString.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/var/toString.js rename to ui/static/bower_components/jquery/src/var/toString.js diff --git a/themes/bootstrap/public/bower_components/jquery/src/wrap.js b/ui/static/bower_components/jquery/src/wrap.js similarity index 100% rename from themes/bootstrap/public/bower_components/jquery/src/wrap.js rename to ui/static/bower_components/jquery/src/wrap.js diff --git a/themes/bootstrap/public/bower_components/moment/.bower.json b/ui/static/bower_components/moment/.bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/moment/.bower.json rename to ui/static/bower_components/moment/.bower.json diff --git a/themes/bootstrap/public/bower_components/moment/CHANGELOG.md b/ui/static/bower_components/moment/CHANGELOG.md similarity index 100% rename from themes/bootstrap/public/bower_components/moment/CHANGELOG.md rename to ui/static/bower_components/moment/CHANGELOG.md diff --git a/themes/bootstrap/public/bower_components/moment/LICENSE b/ui/static/bower_components/moment/LICENSE similarity index 100% rename from themes/bootstrap/public/bower_components/moment/LICENSE rename to ui/static/bower_components/moment/LICENSE diff --git a/themes/bootstrap/public/bower_components/moment/README.md b/ui/static/bower_components/moment/README.md similarity index 100% rename from themes/bootstrap/public/bower_components/moment/README.md rename to ui/static/bower_components/moment/README.md diff --git a/themes/bootstrap/public/bower_components/moment/bower.json b/ui/static/bower_components/moment/bower.json similarity index 100% rename from themes/bootstrap/public/bower_components/moment/bower.json rename to ui/static/bower_components/moment/bower.json diff --git a/themes/bootstrap/public/bower_components/moment/locale/af.js b/ui/static/bower_components/moment/locale/af.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/af.js rename to ui/static/bower_components/moment/locale/af.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar-dz.js b/ui/static/bower_components/moment/locale/ar-dz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar-dz.js rename to ui/static/bower_components/moment/locale/ar-dz.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar-ly.js b/ui/static/bower_components/moment/locale/ar-ly.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar-ly.js rename to ui/static/bower_components/moment/locale/ar-ly.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar-ma.js b/ui/static/bower_components/moment/locale/ar-ma.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar-ma.js rename to ui/static/bower_components/moment/locale/ar-ma.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar-sa.js b/ui/static/bower_components/moment/locale/ar-sa.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar-sa.js rename to ui/static/bower_components/moment/locale/ar-sa.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar-tn.js b/ui/static/bower_components/moment/locale/ar-tn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar-tn.js rename to ui/static/bower_components/moment/locale/ar-tn.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ar.js b/ui/static/bower_components/moment/locale/ar.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ar.js rename to ui/static/bower_components/moment/locale/ar.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/az.js b/ui/static/bower_components/moment/locale/az.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/az.js rename to ui/static/bower_components/moment/locale/az.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/be.js b/ui/static/bower_components/moment/locale/be.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/be.js rename to ui/static/bower_components/moment/locale/be.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/bg.js b/ui/static/bower_components/moment/locale/bg.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/bg.js rename to ui/static/bower_components/moment/locale/bg.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/bn.js b/ui/static/bower_components/moment/locale/bn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/bn.js rename to ui/static/bower_components/moment/locale/bn.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/bo.js b/ui/static/bower_components/moment/locale/bo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/bo.js rename to ui/static/bower_components/moment/locale/bo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/br.js b/ui/static/bower_components/moment/locale/br.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/br.js rename to ui/static/bower_components/moment/locale/br.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/bs.js b/ui/static/bower_components/moment/locale/bs.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/bs.js rename to ui/static/bower_components/moment/locale/bs.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ca.js b/ui/static/bower_components/moment/locale/ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ca.js rename to ui/static/bower_components/moment/locale/ca.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/cs.js b/ui/static/bower_components/moment/locale/cs.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/cs.js rename to ui/static/bower_components/moment/locale/cs.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/cv.js b/ui/static/bower_components/moment/locale/cv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/cv.js rename to ui/static/bower_components/moment/locale/cv.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/cy.js b/ui/static/bower_components/moment/locale/cy.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/cy.js rename to ui/static/bower_components/moment/locale/cy.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/da.js b/ui/static/bower_components/moment/locale/da.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/da.js rename to ui/static/bower_components/moment/locale/da.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/de-at.js b/ui/static/bower_components/moment/locale/de-at.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/de-at.js rename to ui/static/bower_components/moment/locale/de-at.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/de.js b/ui/static/bower_components/moment/locale/de.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/de.js rename to ui/static/bower_components/moment/locale/de.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/dv.js b/ui/static/bower_components/moment/locale/dv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/dv.js rename to ui/static/bower_components/moment/locale/dv.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/el.js b/ui/static/bower_components/moment/locale/el.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/el.js rename to ui/static/bower_components/moment/locale/el.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/en-au.js b/ui/static/bower_components/moment/locale/en-au.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/en-au.js rename to ui/static/bower_components/moment/locale/en-au.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/en-ca.js b/ui/static/bower_components/moment/locale/en-ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/en-ca.js rename to ui/static/bower_components/moment/locale/en-ca.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/en-gb.js b/ui/static/bower_components/moment/locale/en-gb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/en-gb.js rename to ui/static/bower_components/moment/locale/en-gb.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/en-ie.js b/ui/static/bower_components/moment/locale/en-ie.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/en-ie.js rename to ui/static/bower_components/moment/locale/en-ie.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/en-nz.js b/ui/static/bower_components/moment/locale/en-nz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/en-nz.js rename to ui/static/bower_components/moment/locale/en-nz.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/eo.js b/ui/static/bower_components/moment/locale/eo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/eo.js rename to ui/static/bower_components/moment/locale/eo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/es-do.js b/ui/static/bower_components/moment/locale/es-do.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/es-do.js rename to ui/static/bower_components/moment/locale/es-do.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/es.js b/ui/static/bower_components/moment/locale/es.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/es.js rename to ui/static/bower_components/moment/locale/es.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/et.js b/ui/static/bower_components/moment/locale/et.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/et.js rename to ui/static/bower_components/moment/locale/et.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/eu.js b/ui/static/bower_components/moment/locale/eu.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/eu.js rename to ui/static/bower_components/moment/locale/eu.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fa.js b/ui/static/bower_components/moment/locale/fa.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fa.js rename to ui/static/bower_components/moment/locale/fa.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fi.js b/ui/static/bower_components/moment/locale/fi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fi.js rename to ui/static/bower_components/moment/locale/fi.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fo.js b/ui/static/bower_components/moment/locale/fo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fo.js rename to ui/static/bower_components/moment/locale/fo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fr-ca.js b/ui/static/bower_components/moment/locale/fr-ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fr-ca.js rename to ui/static/bower_components/moment/locale/fr-ca.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fr-ch.js b/ui/static/bower_components/moment/locale/fr-ch.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fr-ch.js rename to ui/static/bower_components/moment/locale/fr-ch.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fr.js b/ui/static/bower_components/moment/locale/fr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fr.js rename to ui/static/bower_components/moment/locale/fr.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/fy.js b/ui/static/bower_components/moment/locale/fy.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/fy.js rename to ui/static/bower_components/moment/locale/fy.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/gd.js b/ui/static/bower_components/moment/locale/gd.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/gd.js rename to ui/static/bower_components/moment/locale/gd.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/gl.js b/ui/static/bower_components/moment/locale/gl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/gl.js rename to ui/static/bower_components/moment/locale/gl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/he.js b/ui/static/bower_components/moment/locale/he.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/he.js rename to ui/static/bower_components/moment/locale/he.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/hi.js b/ui/static/bower_components/moment/locale/hi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/hi.js rename to ui/static/bower_components/moment/locale/hi.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/hr.js b/ui/static/bower_components/moment/locale/hr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/hr.js rename to ui/static/bower_components/moment/locale/hr.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/hu.js b/ui/static/bower_components/moment/locale/hu.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/hu.js rename to ui/static/bower_components/moment/locale/hu.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/hy-am.js b/ui/static/bower_components/moment/locale/hy-am.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/hy-am.js rename to ui/static/bower_components/moment/locale/hy-am.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/id.js b/ui/static/bower_components/moment/locale/id.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/id.js rename to ui/static/bower_components/moment/locale/id.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/is.js b/ui/static/bower_components/moment/locale/is.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/is.js rename to ui/static/bower_components/moment/locale/is.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/it.js b/ui/static/bower_components/moment/locale/it.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/it.js rename to ui/static/bower_components/moment/locale/it.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ja.js b/ui/static/bower_components/moment/locale/ja.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ja.js rename to ui/static/bower_components/moment/locale/ja.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/jv.js b/ui/static/bower_components/moment/locale/jv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/jv.js rename to ui/static/bower_components/moment/locale/jv.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ka.js b/ui/static/bower_components/moment/locale/ka.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ka.js rename to ui/static/bower_components/moment/locale/ka.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/kk.js b/ui/static/bower_components/moment/locale/kk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/kk.js rename to ui/static/bower_components/moment/locale/kk.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/km.js b/ui/static/bower_components/moment/locale/km.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/km.js rename to ui/static/bower_components/moment/locale/km.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ko.js b/ui/static/bower_components/moment/locale/ko.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ko.js rename to ui/static/bower_components/moment/locale/ko.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ky.js b/ui/static/bower_components/moment/locale/ky.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ky.js rename to ui/static/bower_components/moment/locale/ky.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/lb.js b/ui/static/bower_components/moment/locale/lb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/lb.js rename to ui/static/bower_components/moment/locale/lb.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/lo.js b/ui/static/bower_components/moment/locale/lo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/lo.js rename to ui/static/bower_components/moment/locale/lo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/lt.js b/ui/static/bower_components/moment/locale/lt.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/lt.js rename to ui/static/bower_components/moment/locale/lt.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/lv.js b/ui/static/bower_components/moment/locale/lv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/lv.js rename to ui/static/bower_components/moment/locale/lv.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/me.js b/ui/static/bower_components/moment/locale/me.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/me.js rename to ui/static/bower_components/moment/locale/me.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/mi.js b/ui/static/bower_components/moment/locale/mi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/mi.js rename to ui/static/bower_components/moment/locale/mi.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/mk.js b/ui/static/bower_components/moment/locale/mk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/mk.js rename to ui/static/bower_components/moment/locale/mk.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ml.js b/ui/static/bower_components/moment/locale/ml.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ml.js rename to ui/static/bower_components/moment/locale/ml.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/mr.js b/ui/static/bower_components/moment/locale/mr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/mr.js rename to ui/static/bower_components/moment/locale/mr.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ms-my.js b/ui/static/bower_components/moment/locale/ms-my.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ms-my.js rename to ui/static/bower_components/moment/locale/ms-my.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ms.js b/ui/static/bower_components/moment/locale/ms.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ms.js rename to ui/static/bower_components/moment/locale/ms.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/my.js b/ui/static/bower_components/moment/locale/my.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/my.js rename to ui/static/bower_components/moment/locale/my.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/nb.js b/ui/static/bower_components/moment/locale/nb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/nb.js rename to ui/static/bower_components/moment/locale/nb.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ne.js b/ui/static/bower_components/moment/locale/ne.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ne.js rename to ui/static/bower_components/moment/locale/ne.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/nl-be.js b/ui/static/bower_components/moment/locale/nl-be.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/nl-be.js rename to ui/static/bower_components/moment/locale/nl-be.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/nl.js b/ui/static/bower_components/moment/locale/nl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/nl.js rename to ui/static/bower_components/moment/locale/nl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/nn.js b/ui/static/bower_components/moment/locale/nn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/nn.js rename to ui/static/bower_components/moment/locale/nn.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/pa-in.js b/ui/static/bower_components/moment/locale/pa-in.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/pa-in.js rename to ui/static/bower_components/moment/locale/pa-in.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/pl.js b/ui/static/bower_components/moment/locale/pl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/pl.js rename to ui/static/bower_components/moment/locale/pl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/pt-br.js b/ui/static/bower_components/moment/locale/pt-br.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/pt-br.js rename to ui/static/bower_components/moment/locale/pt-br.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/pt.js b/ui/static/bower_components/moment/locale/pt.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/pt.js rename to ui/static/bower_components/moment/locale/pt.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ro.js b/ui/static/bower_components/moment/locale/ro.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ro.js rename to ui/static/bower_components/moment/locale/ro.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ru.js b/ui/static/bower_components/moment/locale/ru.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ru.js rename to ui/static/bower_components/moment/locale/ru.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/se.js b/ui/static/bower_components/moment/locale/se.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/se.js rename to ui/static/bower_components/moment/locale/se.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/si.js b/ui/static/bower_components/moment/locale/si.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/si.js rename to ui/static/bower_components/moment/locale/si.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sk.js b/ui/static/bower_components/moment/locale/sk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sk.js rename to ui/static/bower_components/moment/locale/sk.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sl.js b/ui/static/bower_components/moment/locale/sl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sl.js rename to ui/static/bower_components/moment/locale/sl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sq.js b/ui/static/bower_components/moment/locale/sq.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sq.js rename to ui/static/bower_components/moment/locale/sq.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sr-cyrl.js b/ui/static/bower_components/moment/locale/sr-cyrl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sr-cyrl.js rename to ui/static/bower_components/moment/locale/sr-cyrl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sr.js b/ui/static/bower_components/moment/locale/sr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sr.js rename to ui/static/bower_components/moment/locale/sr.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ss.js b/ui/static/bower_components/moment/locale/ss.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ss.js rename to ui/static/bower_components/moment/locale/ss.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sv.js b/ui/static/bower_components/moment/locale/sv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sv.js rename to ui/static/bower_components/moment/locale/sv.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/sw.js b/ui/static/bower_components/moment/locale/sw.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/sw.js rename to ui/static/bower_components/moment/locale/sw.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/ta.js b/ui/static/bower_components/moment/locale/ta.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/ta.js rename to ui/static/bower_components/moment/locale/ta.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/te.js b/ui/static/bower_components/moment/locale/te.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/te.js rename to ui/static/bower_components/moment/locale/te.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tet.js b/ui/static/bower_components/moment/locale/tet.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tet.js rename to ui/static/bower_components/moment/locale/tet.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/th.js b/ui/static/bower_components/moment/locale/th.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/th.js rename to ui/static/bower_components/moment/locale/th.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tl-ph.js b/ui/static/bower_components/moment/locale/tl-ph.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tl-ph.js rename to ui/static/bower_components/moment/locale/tl-ph.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tlh.js b/ui/static/bower_components/moment/locale/tlh.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tlh.js rename to ui/static/bower_components/moment/locale/tlh.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tr.js b/ui/static/bower_components/moment/locale/tr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tr.js rename to ui/static/bower_components/moment/locale/tr.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tzl.js b/ui/static/bower_components/moment/locale/tzl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tzl.js rename to ui/static/bower_components/moment/locale/tzl.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tzm-latn.js b/ui/static/bower_components/moment/locale/tzm-latn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tzm-latn.js rename to ui/static/bower_components/moment/locale/tzm-latn.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/tzm.js b/ui/static/bower_components/moment/locale/tzm.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/tzm.js rename to ui/static/bower_components/moment/locale/tzm.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/uk.js b/ui/static/bower_components/moment/locale/uk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/uk.js rename to ui/static/bower_components/moment/locale/uk.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/uz.js b/ui/static/bower_components/moment/locale/uz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/uz.js rename to ui/static/bower_components/moment/locale/uz.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/vi.js b/ui/static/bower_components/moment/locale/vi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/vi.js rename to ui/static/bower_components/moment/locale/vi.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/x-pseudo.js b/ui/static/bower_components/moment/locale/x-pseudo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/x-pseudo.js rename to ui/static/bower_components/moment/locale/x-pseudo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/yo.js b/ui/static/bower_components/moment/locale/yo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/yo.js rename to ui/static/bower_components/moment/locale/yo.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/zh-cn.js b/ui/static/bower_components/moment/locale/zh-cn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/zh-cn.js rename to ui/static/bower_components/moment/locale/zh-cn.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/zh-hk.js b/ui/static/bower_components/moment/locale/zh-hk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/zh-hk.js rename to ui/static/bower_components/moment/locale/zh-hk.js diff --git a/themes/bootstrap/public/bower_components/moment/locale/zh-tw.js b/ui/static/bower_components/moment/locale/zh-tw.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/locale/zh-tw.js rename to ui/static/bower_components/moment/locale/zh-tw.js diff --git a/themes/bootstrap/public/bower_components/moment/min/locales.js b/ui/static/bower_components/moment/min/locales.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/locales.js rename to ui/static/bower_components/moment/min/locales.js diff --git a/themes/bootstrap/public/bower_components/moment/min/locales.min.js b/ui/static/bower_components/moment/min/locales.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/locales.min.js rename to ui/static/bower_components/moment/min/locales.min.js diff --git a/themes/bootstrap/public/bower_components/moment/min/moment-with-locales.js b/ui/static/bower_components/moment/min/moment-with-locales.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/moment-with-locales.js rename to ui/static/bower_components/moment/min/moment-with-locales.js diff --git a/themes/bootstrap/public/bower_components/moment/min/moment-with-locales.min.js b/ui/static/bower_components/moment/min/moment-with-locales.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/moment-with-locales.min.js rename to ui/static/bower_components/moment/min/moment-with-locales.min.js diff --git a/themes/bootstrap/public/bower_components/moment/min/moment.min.js b/ui/static/bower_components/moment/min/moment.min.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/moment.min.js rename to ui/static/bower_components/moment/min/moment.min.js diff --git a/themes/bootstrap/public/bower_components/moment/min/tests.js b/ui/static/bower_components/moment/min/tests.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/min/tests.js rename to ui/static/bower_components/moment/min/tests.js diff --git a/themes/bootstrap/public/bower_components/moment/moment.d.ts b/ui/static/bower_components/moment/moment.d.ts similarity index 100% rename from themes/bootstrap/public/bower_components/moment/moment.d.ts rename to ui/static/bower_components/moment/moment.d.ts diff --git a/themes/bootstrap/public/bower_components/moment/moment.js b/ui/static/bower_components/moment/moment.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/moment.js rename to ui/static/bower_components/moment/moment.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/check-overflow.js b/ui/static/bower_components/moment/src/lib/create/check-overflow.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/check-overflow.js rename to ui/static/bower_components/moment/src/lib/create/check-overflow.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/date-from-array.js b/ui/static/bower_components/moment/src/lib/create/date-from-array.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/date-from-array.js rename to ui/static/bower_components/moment/src/lib/create/date-from-array.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-anything.js b/ui/static/bower_components/moment/src/lib/create/from-anything.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-anything.js rename to ui/static/bower_components/moment/src/lib/create/from-anything.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-array.js b/ui/static/bower_components/moment/src/lib/create/from-array.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-array.js rename to ui/static/bower_components/moment/src/lib/create/from-array.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-object.js b/ui/static/bower_components/moment/src/lib/create/from-object.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-object.js rename to ui/static/bower_components/moment/src/lib/create/from-object.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-string-and-array.js b/ui/static/bower_components/moment/src/lib/create/from-string-and-array.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-string-and-array.js rename to ui/static/bower_components/moment/src/lib/create/from-string-and-array.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-string-and-format.js b/ui/static/bower_components/moment/src/lib/create/from-string-and-format.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-string-and-format.js rename to ui/static/bower_components/moment/src/lib/create/from-string-and-format.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/from-string.js b/ui/static/bower_components/moment/src/lib/create/from-string.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/from-string.js rename to ui/static/bower_components/moment/src/lib/create/from-string.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/local.js b/ui/static/bower_components/moment/src/lib/create/local.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/local.js rename to ui/static/bower_components/moment/src/lib/create/local.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/parsing-flags.js b/ui/static/bower_components/moment/src/lib/create/parsing-flags.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/parsing-flags.js rename to ui/static/bower_components/moment/src/lib/create/parsing-flags.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/utc.js b/ui/static/bower_components/moment/src/lib/create/utc.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/utc.js rename to ui/static/bower_components/moment/src/lib/create/utc.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/create/valid.js b/ui/static/bower_components/moment/src/lib/create/valid.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/create/valid.js rename to ui/static/bower_components/moment/src/lib/create/valid.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/abs.js b/ui/static/bower_components/moment/src/lib/duration/abs.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/abs.js rename to ui/static/bower_components/moment/src/lib/duration/abs.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/add-subtract.js b/ui/static/bower_components/moment/src/lib/duration/add-subtract.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/add-subtract.js rename to ui/static/bower_components/moment/src/lib/duration/add-subtract.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/as.js b/ui/static/bower_components/moment/src/lib/duration/as.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/as.js rename to ui/static/bower_components/moment/src/lib/duration/as.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/bubble.js b/ui/static/bower_components/moment/src/lib/duration/bubble.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/bubble.js rename to ui/static/bower_components/moment/src/lib/duration/bubble.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/constructor.js b/ui/static/bower_components/moment/src/lib/duration/constructor.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/constructor.js rename to ui/static/bower_components/moment/src/lib/duration/constructor.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/create.js b/ui/static/bower_components/moment/src/lib/duration/create.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/create.js rename to ui/static/bower_components/moment/src/lib/duration/create.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/duration.js b/ui/static/bower_components/moment/src/lib/duration/duration.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/duration.js rename to ui/static/bower_components/moment/src/lib/duration/duration.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/get.js b/ui/static/bower_components/moment/src/lib/duration/get.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/get.js rename to ui/static/bower_components/moment/src/lib/duration/get.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/humanize.js b/ui/static/bower_components/moment/src/lib/duration/humanize.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/humanize.js rename to ui/static/bower_components/moment/src/lib/duration/humanize.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/iso-string.js b/ui/static/bower_components/moment/src/lib/duration/iso-string.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/iso-string.js rename to ui/static/bower_components/moment/src/lib/duration/iso-string.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/duration/prototype.js b/ui/static/bower_components/moment/src/lib/duration/prototype.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/duration/prototype.js rename to ui/static/bower_components/moment/src/lib/duration/prototype.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/format/format.js b/ui/static/bower_components/moment/src/lib/format/format.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/format/format.js rename to ui/static/bower_components/moment/src/lib/format/format.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/base-config.js b/ui/static/bower_components/moment/src/lib/locale/base-config.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/base-config.js rename to ui/static/bower_components/moment/src/lib/locale/base-config.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/calendar.js b/ui/static/bower_components/moment/src/lib/locale/calendar.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/calendar.js rename to ui/static/bower_components/moment/src/lib/locale/calendar.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/constructor.js b/ui/static/bower_components/moment/src/lib/locale/constructor.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/constructor.js rename to ui/static/bower_components/moment/src/lib/locale/constructor.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/en.js b/ui/static/bower_components/moment/src/lib/locale/en.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/en.js rename to ui/static/bower_components/moment/src/lib/locale/en.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/formats.js b/ui/static/bower_components/moment/src/lib/locale/formats.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/formats.js rename to ui/static/bower_components/moment/src/lib/locale/formats.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/invalid.js b/ui/static/bower_components/moment/src/lib/locale/invalid.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/invalid.js rename to ui/static/bower_components/moment/src/lib/locale/invalid.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/lists.js b/ui/static/bower_components/moment/src/lib/locale/lists.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/lists.js rename to ui/static/bower_components/moment/src/lib/locale/lists.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/locale.js b/ui/static/bower_components/moment/src/lib/locale/locale.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/locale.js rename to ui/static/bower_components/moment/src/lib/locale/locale.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/locales.js b/ui/static/bower_components/moment/src/lib/locale/locales.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/locales.js rename to ui/static/bower_components/moment/src/lib/locale/locales.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/ordinal.js b/ui/static/bower_components/moment/src/lib/locale/ordinal.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/ordinal.js rename to ui/static/bower_components/moment/src/lib/locale/ordinal.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/pre-post-format.js b/ui/static/bower_components/moment/src/lib/locale/pre-post-format.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/pre-post-format.js rename to ui/static/bower_components/moment/src/lib/locale/pre-post-format.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/prototype.js b/ui/static/bower_components/moment/src/lib/locale/prototype.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/prototype.js rename to ui/static/bower_components/moment/src/lib/locale/prototype.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/relative.js b/ui/static/bower_components/moment/src/lib/locale/relative.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/relative.js rename to ui/static/bower_components/moment/src/lib/locale/relative.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/locale/set.js b/ui/static/bower_components/moment/src/lib/locale/set.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/locale/set.js rename to ui/static/bower_components/moment/src/lib/locale/set.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/add-subtract.js b/ui/static/bower_components/moment/src/lib/moment/add-subtract.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/add-subtract.js rename to ui/static/bower_components/moment/src/lib/moment/add-subtract.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/calendar.js b/ui/static/bower_components/moment/src/lib/moment/calendar.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/calendar.js rename to ui/static/bower_components/moment/src/lib/moment/calendar.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/clone.js b/ui/static/bower_components/moment/src/lib/moment/clone.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/clone.js rename to ui/static/bower_components/moment/src/lib/moment/clone.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/compare.js b/ui/static/bower_components/moment/src/lib/moment/compare.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/compare.js rename to ui/static/bower_components/moment/src/lib/moment/compare.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/constructor.js b/ui/static/bower_components/moment/src/lib/moment/constructor.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/constructor.js rename to ui/static/bower_components/moment/src/lib/moment/constructor.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/creation-data.js b/ui/static/bower_components/moment/src/lib/moment/creation-data.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/creation-data.js rename to ui/static/bower_components/moment/src/lib/moment/creation-data.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/diff.js b/ui/static/bower_components/moment/src/lib/moment/diff.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/diff.js rename to ui/static/bower_components/moment/src/lib/moment/diff.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/format.js b/ui/static/bower_components/moment/src/lib/moment/format.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/format.js rename to ui/static/bower_components/moment/src/lib/moment/format.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/from.js b/ui/static/bower_components/moment/src/lib/moment/from.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/from.js rename to ui/static/bower_components/moment/src/lib/moment/from.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/get-set.js b/ui/static/bower_components/moment/src/lib/moment/get-set.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/get-set.js rename to ui/static/bower_components/moment/src/lib/moment/get-set.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/locale.js b/ui/static/bower_components/moment/src/lib/moment/locale.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/locale.js rename to ui/static/bower_components/moment/src/lib/moment/locale.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/min-max.js b/ui/static/bower_components/moment/src/lib/moment/min-max.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/min-max.js rename to ui/static/bower_components/moment/src/lib/moment/min-max.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/moment.js b/ui/static/bower_components/moment/src/lib/moment/moment.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/moment.js rename to ui/static/bower_components/moment/src/lib/moment/moment.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/now.js b/ui/static/bower_components/moment/src/lib/moment/now.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/now.js rename to ui/static/bower_components/moment/src/lib/moment/now.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/prototype.js b/ui/static/bower_components/moment/src/lib/moment/prototype.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/prototype.js rename to ui/static/bower_components/moment/src/lib/moment/prototype.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/start-end-of.js b/ui/static/bower_components/moment/src/lib/moment/start-end-of.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/start-end-of.js rename to ui/static/bower_components/moment/src/lib/moment/start-end-of.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/to-type.js b/ui/static/bower_components/moment/src/lib/moment/to-type.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/to-type.js rename to ui/static/bower_components/moment/src/lib/moment/to-type.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/to.js b/ui/static/bower_components/moment/src/lib/moment/to.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/to.js rename to ui/static/bower_components/moment/src/lib/moment/to.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/moment/valid.js b/ui/static/bower_components/moment/src/lib/moment/valid.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/moment/valid.js rename to ui/static/bower_components/moment/src/lib/moment/valid.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/parse/regex.js b/ui/static/bower_components/moment/src/lib/parse/regex.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/parse/regex.js rename to ui/static/bower_components/moment/src/lib/parse/regex.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/parse/token.js b/ui/static/bower_components/moment/src/lib/parse/token.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/parse/token.js rename to ui/static/bower_components/moment/src/lib/parse/token.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/aliases.js b/ui/static/bower_components/moment/src/lib/units/aliases.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/aliases.js rename to ui/static/bower_components/moment/src/lib/units/aliases.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/constants.js b/ui/static/bower_components/moment/src/lib/units/constants.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/constants.js rename to ui/static/bower_components/moment/src/lib/units/constants.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-month.js b/ui/static/bower_components/moment/src/lib/units/day-of-month.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-month.js rename to ui/static/bower_components/moment/src/lib/units/day-of-month.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-week.js b/ui/static/bower_components/moment/src/lib/units/day-of-week.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-week.js rename to ui/static/bower_components/moment/src/lib/units/day-of-week.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-year.js b/ui/static/bower_components/moment/src/lib/units/day-of-year.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/day-of-year.js rename to ui/static/bower_components/moment/src/lib/units/day-of-year.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/hour.js b/ui/static/bower_components/moment/src/lib/units/hour.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/hour.js rename to ui/static/bower_components/moment/src/lib/units/hour.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/millisecond.js b/ui/static/bower_components/moment/src/lib/units/millisecond.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/millisecond.js rename to ui/static/bower_components/moment/src/lib/units/millisecond.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/minute.js b/ui/static/bower_components/moment/src/lib/units/minute.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/minute.js rename to ui/static/bower_components/moment/src/lib/units/minute.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/month.js b/ui/static/bower_components/moment/src/lib/units/month.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/month.js rename to ui/static/bower_components/moment/src/lib/units/month.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/offset.js b/ui/static/bower_components/moment/src/lib/units/offset.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/offset.js rename to ui/static/bower_components/moment/src/lib/units/offset.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/priorities.js b/ui/static/bower_components/moment/src/lib/units/priorities.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/priorities.js rename to ui/static/bower_components/moment/src/lib/units/priorities.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/quarter.js b/ui/static/bower_components/moment/src/lib/units/quarter.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/quarter.js rename to ui/static/bower_components/moment/src/lib/units/quarter.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/second.js b/ui/static/bower_components/moment/src/lib/units/second.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/second.js rename to ui/static/bower_components/moment/src/lib/units/second.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/timestamp.js b/ui/static/bower_components/moment/src/lib/units/timestamp.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/timestamp.js rename to ui/static/bower_components/moment/src/lib/units/timestamp.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/timezone.js b/ui/static/bower_components/moment/src/lib/units/timezone.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/timezone.js rename to ui/static/bower_components/moment/src/lib/units/timezone.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/units.js b/ui/static/bower_components/moment/src/lib/units/units.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/units.js rename to ui/static/bower_components/moment/src/lib/units/units.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/week-calendar-utils.js b/ui/static/bower_components/moment/src/lib/units/week-calendar-utils.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/week-calendar-utils.js rename to ui/static/bower_components/moment/src/lib/units/week-calendar-utils.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/week-year.js b/ui/static/bower_components/moment/src/lib/units/week-year.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/week-year.js rename to ui/static/bower_components/moment/src/lib/units/week-year.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/week.js b/ui/static/bower_components/moment/src/lib/units/week.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/week.js rename to ui/static/bower_components/moment/src/lib/units/week.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/units/year.js b/ui/static/bower_components/moment/src/lib/units/year.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/units/year.js rename to ui/static/bower_components/moment/src/lib/units/year.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-ceil.js b/ui/static/bower_components/moment/src/lib/utils/abs-ceil.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-ceil.js rename to ui/static/bower_components/moment/src/lib/utils/abs-ceil.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-floor.js b/ui/static/bower_components/moment/src/lib/utils/abs-floor.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-floor.js rename to ui/static/bower_components/moment/src/lib/utils/abs-floor.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-round.js b/ui/static/bower_components/moment/src/lib/utils/abs-round.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/abs-round.js rename to ui/static/bower_components/moment/src/lib/utils/abs-round.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/compare-arrays.js b/ui/static/bower_components/moment/src/lib/utils/compare-arrays.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/compare-arrays.js rename to ui/static/bower_components/moment/src/lib/utils/compare-arrays.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/defaults.js b/ui/static/bower_components/moment/src/lib/utils/defaults.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/defaults.js rename to ui/static/bower_components/moment/src/lib/utils/defaults.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/deprecate.js b/ui/static/bower_components/moment/src/lib/utils/deprecate.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/deprecate.js rename to ui/static/bower_components/moment/src/lib/utils/deprecate.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/extend.js b/ui/static/bower_components/moment/src/lib/utils/extend.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/extend.js rename to ui/static/bower_components/moment/src/lib/utils/extend.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/has-own-prop.js b/ui/static/bower_components/moment/src/lib/utils/has-own-prop.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/has-own-prop.js rename to ui/static/bower_components/moment/src/lib/utils/has-own-prop.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/hooks.js b/ui/static/bower_components/moment/src/lib/utils/hooks.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/hooks.js rename to ui/static/bower_components/moment/src/lib/utils/hooks.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/index-of.js b/ui/static/bower_components/moment/src/lib/utils/index-of.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/index-of.js rename to ui/static/bower_components/moment/src/lib/utils/index-of.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-array.js b/ui/static/bower_components/moment/src/lib/utils/is-array.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-array.js rename to ui/static/bower_components/moment/src/lib/utils/is-array.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-date.js b/ui/static/bower_components/moment/src/lib/utils/is-date.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-date.js rename to ui/static/bower_components/moment/src/lib/utils/is-date.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-function.js b/ui/static/bower_components/moment/src/lib/utils/is-function.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-function.js rename to ui/static/bower_components/moment/src/lib/utils/is-function.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-number.js b/ui/static/bower_components/moment/src/lib/utils/is-number.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-number.js rename to ui/static/bower_components/moment/src/lib/utils/is-number.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-object-empty.js b/ui/static/bower_components/moment/src/lib/utils/is-object-empty.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-object-empty.js rename to ui/static/bower_components/moment/src/lib/utils/is-object-empty.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-object.js b/ui/static/bower_components/moment/src/lib/utils/is-object.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-object.js rename to ui/static/bower_components/moment/src/lib/utils/is-object.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/is-undefined.js b/ui/static/bower_components/moment/src/lib/utils/is-undefined.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/is-undefined.js rename to ui/static/bower_components/moment/src/lib/utils/is-undefined.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/keys.js b/ui/static/bower_components/moment/src/lib/utils/keys.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/keys.js rename to ui/static/bower_components/moment/src/lib/utils/keys.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/map.js b/ui/static/bower_components/moment/src/lib/utils/map.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/map.js rename to ui/static/bower_components/moment/src/lib/utils/map.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/some.js b/ui/static/bower_components/moment/src/lib/utils/some.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/some.js rename to ui/static/bower_components/moment/src/lib/utils/some.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/to-int.js b/ui/static/bower_components/moment/src/lib/utils/to-int.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/to-int.js rename to ui/static/bower_components/moment/src/lib/utils/to-int.js diff --git a/themes/bootstrap/public/bower_components/moment/src/lib/utils/zero-fill.js b/ui/static/bower_components/moment/src/lib/utils/zero-fill.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/lib/utils/zero-fill.js rename to ui/static/bower_components/moment/src/lib/utils/zero-fill.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/af.js b/ui/static/bower_components/moment/src/locale/af.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/af.js rename to ui/static/bower_components/moment/src/locale/af.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar-dz.js b/ui/static/bower_components/moment/src/locale/ar-dz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar-dz.js rename to ui/static/bower_components/moment/src/locale/ar-dz.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar-ly.js b/ui/static/bower_components/moment/src/locale/ar-ly.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar-ly.js rename to ui/static/bower_components/moment/src/locale/ar-ly.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar-ma.js b/ui/static/bower_components/moment/src/locale/ar-ma.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar-ma.js rename to ui/static/bower_components/moment/src/locale/ar-ma.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar-sa.js b/ui/static/bower_components/moment/src/locale/ar-sa.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar-sa.js rename to ui/static/bower_components/moment/src/locale/ar-sa.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar-tn.js b/ui/static/bower_components/moment/src/locale/ar-tn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar-tn.js rename to ui/static/bower_components/moment/src/locale/ar-tn.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ar.js b/ui/static/bower_components/moment/src/locale/ar.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ar.js rename to ui/static/bower_components/moment/src/locale/ar.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/az.js b/ui/static/bower_components/moment/src/locale/az.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/az.js rename to ui/static/bower_components/moment/src/locale/az.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/be.js b/ui/static/bower_components/moment/src/locale/be.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/be.js rename to ui/static/bower_components/moment/src/locale/be.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/bg.js b/ui/static/bower_components/moment/src/locale/bg.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/bg.js rename to ui/static/bower_components/moment/src/locale/bg.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/bn.js b/ui/static/bower_components/moment/src/locale/bn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/bn.js rename to ui/static/bower_components/moment/src/locale/bn.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/bo.js b/ui/static/bower_components/moment/src/locale/bo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/bo.js rename to ui/static/bower_components/moment/src/locale/bo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/br.js b/ui/static/bower_components/moment/src/locale/br.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/br.js rename to ui/static/bower_components/moment/src/locale/br.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/bs.js b/ui/static/bower_components/moment/src/locale/bs.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/bs.js rename to ui/static/bower_components/moment/src/locale/bs.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ca.js b/ui/static/bower_components/moment/src/locale/ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ca.js rename to ui/static/bower_components/moment/src/locale/ca.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/cs.js b/ui/static/bower_components/moment/src/locale/cs.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/cs.js rename to ui/static/bower_components/moment/src/locale/cs.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/cv.js b/ui/static/bower_components/moment/src/locale/cv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/cv.js rename to ui/static/bower_components/moment/src/locale/cv.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/cy.js b/ui/static/bower_components/moment/src/locale/cy.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/cy.js rename to ui/static/bower_components/moment/src/locale/cy.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/da.js b/ui/static/bower_components/moment/src/locale/da.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/da.js rename to ui/static/bower_components/moment/src/locale/da.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/de-at.js b/ui/static/bower_components/moment/src/locale/de-at.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/de-at.js rename to ui/static/bower_components/moment/src/locale/de-at.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/de.js b/ui/static/bower_components/moment/src/locale/de.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/de.js rename to ui/static/bower_components/moment/src/locale/de.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/dv.js b/ui/static/bower_components/moment/src/locale/dv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/dv.js rename to ui/static/bower_components/moment/src/locale/dv.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/el.js b/ui/static/bower_components/moment/src/locale/el.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/el.js rename to ui/static/bower_components/moment/src/locale/el.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/en-au.js b/ui/static/bower_components/moment/src/locale/en-au.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/en-au.js rename to ui/static/bower_components/moment/src/locale/en-au.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/en-ca.js b/ui/static/bower_components/moment/src/locale/en-ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/en-ca.js rename to ui/static/bower_components/moment/src/locale/en-ca.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/en-gb.js b/ui/static/bower_components/moment/src/locale/en-gb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/en-gb.js rename to ui/static/bower_components/moment/src/locale/en-gb.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/en-ie.js b/ui/static/bower_components/moment/src/locale/en-ie.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/en-ie.js rename to ui/static/bower_components/moment/src/locale/en-ie.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/en-nz.js b/ui/static/bower_components/moment/src/locale/en-nz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/en-nz.js rename to ui/static/bower_components/moment/src/locale/en-nz.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/eo.js b/ui/static/bower_components/moment/src/locale/eo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/eo.js rename to ui/static/bower_components/moment/src/locale/eo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/es-do.js b/ui/static/bower_components/moment/src/locale/es-do.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/es-do.js rename to ui/static/bower_components/moment/src/locale/es-do.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/es.js b/ui/static/bower_components/moment/src/locale/es.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/es.js rename to ui/static/bower_components/moment/src/locale/es.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/et.js b/ui/static/bower_components/moment/src/locale/et.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/et.js rename to ui/static/bower_components/moment/src/locale/et.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/eu.js b/ui/static/bower_components/moment/src/locale/eu.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/eu.js rename to ui/static/bower_components/moment/src/locale/eu.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fa.js b/ui/static/bower_components/moment/src/locale/fa.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fa.js rename to ui/static/bower_components/moment/src/locale/fa.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fi.js b/ui/static/bower_components/moment/src/locale/fi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fi.js rename to ui/static/bower_components/moment/src/locale/fi.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fo.js b/ui/static/bower_components/moment/src/locale/fo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fo.js rename to ui/static/bower_components/moment/src/locale/fo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fr-ca.js b/ui/static/bower_components/moment/src/locale/fr-ca.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fr-ca.js rename to ui/static/bower_components/moment/src/locale/fr-ca.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fr-ch.js b/ui/static/bower_components/moment/src/locale/fr-ch.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fr-ch.js rename to ui/static/bower_components/moment/src/locale/fr-ch.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fr.js b/ui/static/bower_components/moment/src/locale/fr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fr.js rename to ui/static/bower_components/moment/src/locale/fr.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/fy.js b/ui/static/bower_components/moment/src/locale/fy.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/fy.js rename to ui/static/bower_components/moment/src/locale/fy.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/gd.js b/ui/static/bower_components/moment/src/locale/gd.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/gd.js rename to ui/static/bower_components/moment/src/locale/gd.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/gl.js b/ui/static/bower_components/moment/src/locale/gl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/gl.js rename to ui/static/bower_components/moment/src/locale/gl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/he.js b/ui/static/bower_components/moment/src/locale/he.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/he.js rename to ui/static/bower_components/moment/src/locale/he.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/hi.js b/ui/static/bower_components/moment/src/locale/hi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/hi.js rename to ui/static/bower_components/moment/src/locale/hi.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/hr.js b/ui/static/bower_components/moment/src/locale/hr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/hr.js rename to ui/static/bower_components/moment/src/locale/hr.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/hu.js b/ui/static/bower_components/moment/src/locale/hu.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/hu.js rename to ui/static/bower_components/moment/src/locale/hu.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/hy-am.js b/ui/static/bower_components/moment/src/locale/hy-am.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/hy-am.js rename to ui/static/bower_components/moment/src/locale/hy-am.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/id.js b/ui/static/bower_components/moment/src/locale/id.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/id.js rename to ui/static/bower_components/moment/src/locale/id.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/is.js b/ui/static/bower_components/moment/src/locale/is.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/is.js rename to ui/static/bower_components/moment/src/locale/is.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/it.js b/ui/static/bower_components/moment/src/locale/it.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/it.js rename to ui/static/bower_components/moment/src/locale/it.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ja.js b/ui/static/bower_components/moment/src/locale/ja.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ja.js rename to ui/static/bower_components/moment/src/locale/ja.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/jv.js b/ui/static/bower_components/moment/src/locale/jv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/jv.js rename to ui/static/bower_components/moment/src/locale/jv.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ka.js b/ui/static/bower_components/moment/src/locale/ka.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ka.js rename to ui/static/bower_components/moment/src/locale/ka.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/kk.js b/ui/static/bower_components/moment/src/locale/kk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/kk.js rename to ui/static/bower_components/moment/src/locale/kk.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/km.js b/ui/static/bower_components/moment/src/locale/km.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/km.js rename to ui/static/bower_components/moment/src/locale/km.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ko.js b/ui/static/bower_components/moment/src/locale/ko.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ko.js rename to ui/static/bower_components/moment/src/locale/ko.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ky.js b/ui/static/bower_components/moment/src/locale/ky.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ky.js rename to ui/static/bower_components/moment/src/locale/ky.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/lb.js b/ui/static/bower_components/moment/src/locale/lb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/lb.js rename to ui/static/bower_components/moment/src/locale/lb.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/lo.js b/ui/static/bower_components/moment/src/locale/lo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/lo.js rename to ui/static/bower_components/moment/src/locale/lo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/lt.js b/ui/static/bower_components/moment/src/locale/lt.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/lt.js rename to ui/static/bower_components/moment/src/locale/lt.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/lv.js b/ui/static/bower_components/moment/src/locale/lv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/lv.js rename to ui/static/bower_components/moment/src/locale/lv.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/me.js b/ui/static/bower_components/moment/src/locale/me.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/me.js rename to ui/static/bower_components/moment/src/locale/me.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/mi.js b/ui/static/bower_components/moment/src/locale/mi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/mi.js rename to ui/static/bower_components/moment/src/locale/mi.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/mk.js b/ui/static/bower_components/moment/src/locale/mk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/mk.js rename to ui/static/bower_components/moment/src/locale/mk.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ml.js b/ui/static/bower_components/moment/src/locale/ml.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ml.js rename to ui/static/bower_components/moment/src/locale/ml.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/mr.js b/ui/static/bower_components/moment/src/locale/mr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/mr.js rename to ui/static/bower_components/moment/src/locale/mr.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ms-my.js b/ui/static/bower_components/moment/src/locale/ms-my.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ms-my.js rename to ui/static/bower_components/moment/src/locale/ms-my.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ms.js b/ui/static/bower_components/moment/src/locale/ms.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ms.js rename to ui/static/bower_components/moment/src/locale/ms.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/my.js b/ui/static/bower_components/moment/src/locale/my.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/my.js rename to ui/static/bower_components/moment/src/locale/my.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/nb.js b/ui/static/bower_components/moment/src/locale/nb.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/nb.js rename to ui/static/bower_components/moment/src/locale/nb.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ne.js b/ui/static/bower_components/moment/src/locale/ne.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ne.js rename to ui/static/bower_components/moment/src/locale/ne.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/nl-be.js b/ui/static/bower_components/moment/src/locale/nl-be.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/nl-be.js rename to ui/static/bower_components/moment/src/locale/nl-be.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/nl.js b/ui/static/bower_components/moment/src/locale/nl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/nl.js rename to ui/static/bower_components/moment/src/locale/nl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/nn.js b/ui/static/bower_components/moment/src/locale/nn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/nn.js rename to ui/static/bower_components/moment/src/locale/nn.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/pa-in.js b/ui/static/bower_components/moment/src/locale/pa-in.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/pa-in.js rename to ui/static/bower_components/moment/src/locale/pa-in.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/pl.js b/ui/static/bower_components/moment/src/locale/pl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/pl.js rename to ui/static/bower_components/moment/src/locale/pl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/pt-br.js b/ui/static/bower_components/moment/src/locale/pt-br.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/pt-br.js rename to ui/static/bower_components/moment/src/locale/pt-br.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/pt.js b/ui/static/bower_components/moment/src/locale/pt.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/pt.js rename to ui/static/bower_components/moment/src/locale/pt.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ro.js b/ui/static/bower_components/moment/src/locale/ro.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ro.js rename to ui/static/bower_components/moment/src/locale/ro.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ru.js b/ui/static/bower_components/moment/src/locale/ru.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ru.js rename to ui/static/bower_components/moment/src/locale/ru.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/se.js b/ui/static/bower_components/moment/src/locale/se.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/se.js rename to ui/static/bower_components/moment/src/locale/se.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/si.js b/ui/static/bower_components/moment/src/locale/si.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/si.js rename to ui/static/bower_components/moment/src/locale/si.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sk.js b/ui/static/bower_components/moment/src/locale/sk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sk.js rename to ui/static/bower_components/moment/src/locale/sk.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sl.js b/ui/static/bower_components/moment/src/locale/sl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sl.js rename to ui/static/bower_components/moment/src/locale/sl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sq.js b/ui/static/bower_components/moment/src/locale/sq.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sq.js rename to ui/static/bower_components/moment/src/locale/sq.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sr-cyrl.js b/ui/static/bower_components/moment/src/locale/sr-cyrl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sr-cyrl.js rename to ui/static/bower_components/moment/src/locale/sr-cyrl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sr.js b/ui/static/bower_components/moment/src/locale/sr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sr.js rename to ui/static/bower_components/moment/src/locale/sr.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ss.js b/ui/static/bower_components/moment/src/locale/ss.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ss.js rename to ui/static/bower_components/moment/src/locale/ss.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sv.js b/ui/static/bower_components/moment/src/locale/sv.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sv.js rename to ui/static/bower_components/moment/src/locale/sv.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/sw.js b/ui/static/bower_components/moment/src/locale/sw.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/sw.js rename to ui/static/bower_components/moment/src/locale/sw.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/ta.js b/ui/static/bower_components/moment/src/locale/ta.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/ta.js rename to ui/static/bower_components/moment/src/locale/ta.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/te.js b/ui/static/bower_components/moment/src/locale/te.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/te.js rename to ui/static/bower_components/moment/src/locale/te.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tet.js b/ui/static/bower_components/moment/src/locale/tet.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tet.js rename to ui/static/bower_components/moment/src/locale/tet.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/th.js b/ui/static/bower_components/moment/src/locale/th.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/th.js rename to ui/static/bower_components/moment/src/locale/th.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tl-ph.js b/ui/static/bower_components/moment/src/locale/tl-ph.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tl-ph.js rename to ui/static/bower_components/moment/src/locale/tl-ph.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tlh.js b/ui/static/bower_components/moment/src/locale/tlh.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tlh.js rename to ui/static/bower_components/moment/src/locale/tlh.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tr.js b/ui/static/bower_components/moment/src/locale/tr.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tr.js rename to ui/static/bower_components/moment/src/locale/tr.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tzl.js b/ui/static/bower_components/moment/src/locale/tzl.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tzl.js rename to ui/static/bower_components/moment/src/locale/tzl.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tzm-latn.js b/ui/static/bower_components/moment/src/locale/tzm-latn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tzm-latn.js rename to ui/static/bower_components/moment/src/locale/tzm-latn.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/tzm.js b/ui/static/bower_components/moment/src/locale/tzm.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/tzm.js rename to ui/static/bower_components/moment/src/locale/tzm.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/uk.js b/ui/static/bower_components/moment/src/locale/uk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/uk.js rename to ui/static/bower_components/moment/src/locale/uk.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/uz.js b/ui/static/bower_components/moment/src/locale/uz.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/uz.js rename to ui/static/bower_components/moment/src/locale/uz.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/vi.js b/ui/static/bower_components/moment/src/locale/vi.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/vi.js rename to ui/static/bower_components/moment/src/locale/vi.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/x-pseudo.js b/ui/static/bower_components/moment/src/locale/x-pseudo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/x-pseudo.js rename to ui/static/bower_components/moment/src/locale/x-pseudo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/yo.js b/ui/static/bower_components/moment/src/locale/yo.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/yo.js rename to ui/static/bower_components/moment/src/locale/yo.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/zh-cn.js b/ui/static/bower_components/moment/src/locale/zh-cn.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/zh-cn.js rename to ui/static/bower_components/moment/src/locale/zh-cn.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/zh-hk.js b/ui/static/bower_components/moment/src/locale/zh-hk.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/zh-hk.js rename to ui/static/bower_components/moment/src/locale/zh-hk.js diff --git a/themes/bootstrap/public/bower_components/moment/src/locale/zh-tw.js b/ui/static/bower_components/moment/src/locale/zh-tw.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/locale/zh-tw.js rename to ui/static/bower_components/moment/src/locale/zh-tw.js diff --git a/themes/bootstrap/public/bower_components/moment/src/moment.js b/ui/static/bower_components/moment/src/moment.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/src/moment.js rename to ui/static/bower_components/moment/src/moment.js diff --git a/themes/bootstrap/public/bower_components/moment/templates/default.js b/ui/static/bower_components/moment/templates/default.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/templates/default.js rename to ui/static/bower_components/moment/templates/default.js diff --git a/themes/bootstrap/public/bower_components/moment/templates/locale-header.js b/ui/static/bower_components/moment/templates/locale-header.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/templates/locale-header.js rename to ui/static/bower_components/moment/templates/locale-header.js diff --git a/themes/bootstrap/public/bower_components/moment/templates/test-header.js b/ui/static/bower_components/moment/templates/test-header.js similarity index 100% rename from themes/bootstrap/public/bower_components/moment/templates/test-header.js rename to ui/static/bower_components/moment/templates/test-header.js diff --git a/themes/bootstrap/public/favicon.png b/ui/static/favicon.png similarity index 100% rename from themes/bootstrap/public/favicon.png rename to ui/static/favicon.png diff --git a/themes/bootstrap/public/inbucket.css b/ui/static/inbucket.css similarity index 95% rename from themes/bootstrap/public/inbucket.css rename to ui/static/inbucket.css index c895626..ece379e 100644 --- a/themes/bootstrap/public/inbucket.css +++ b/ui/static/inbucket.css @@ -29,6 +29,10 @@ body { font-size: 18px; } +.message-list-entry .unseen { + font-weight: bold; +} + .message-list-scroll { overflow-y: auto; } diff --git a/themes/bootstrap/public/mailbox.js b/ui/static/mailbox.js similarity index 85% rename from themes/bootstrap/public/mailbox.js rename to ui/static/mailbox.js index 5a624c6..efe52d4 100644 --- a/themes/bootstrap/public/mailbox.js +++ b/ui/static/mailbox.js @@ -5,6 +5,7 @@ var messageListMargin = 275; var clipboard = null; var messageListScroll = false; var messageListData = null; +var seenDelay = null; // clearMessageSearch resets the message list search function clearMessageSearch() { @@ -55,8 +56,11 @@ function loadList() { url: '/api/v1/mailbox/' + mailbox, success: function(data) { messageListData = data.reverse(); + for (i=0; i