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

Merge branch 'release/3.0.0-beta3'

This commit is contained in:
James Hillyerd
2020-09-04 12:45:14 -07:00
49 changed files with 3733 additions and 2873 deletions

View File

@@ -6,3 +6,8 @@ inbucket
inbucket.exe inbucket.exe
swaks-tests swaks-tests
target target
tags
tags.*
ui/dist
ui/elm-stuff
ui/node_modules

15
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Docker Image
on:
push:
branches: [ "master", "develop" ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/build-push-action@v1
with:
repository: inbucket/inbucket
push: false
tag_with_ref: true

5
.gitignore vendored
View File

@@ -21,9 +21,11 @@ _testmain.go
*.exe *.exe
# vim swp files # vim files
*.swp *.swp
*.swo *.swo
tags
tags.*
# Desktop Services Store on macOS # Desktop Services Store on macOS
.DS_Store .DS_Store
@@ -41,6 +43,7 @@ _testmain.go
# Elm UI # Elm UI
# elm-package generated files # elm-package generated files
/ui/index.html
/ui/elm-stuff /ui/elm-stuff
/ui/tests/elm-stuff /ui/tests/elm-stuff
# elm-repl generated files # elm-repl generated files

View File

@@ -1,4 +1,4 @@
sudo: false dist: bionic
env: env:
global: global:
@@ -12,10 +12,13 @@ install:
jobs: jobs:
include: include:
- go: "1.11.x" - go: "1.14.x"
- go: "master" - go: "1.15.x"
- language: elm - language: elm
elm: "0.19.0" elm: "latest-0.19.1"
elm_format: "latest-0.19.1"
elm_test: "latest-0.19.1"
node_js: "10.16.0"
install: install:
- "cd ui" - "cd ui"
- "npm ci" - "npm ci"
@@ -23,9 +26,9 @@ jobs:
- "elm-format --validate ." - "elm-format --validate ."
- "npm run build" - "npm run build"
- stage: deploy - stage: deploy
go: "1.11.x" go: "1.15.x"
before_install: before_install:
- "nvm install 10.13.0" - "nvm install 10.19.0"
install: install:
- "cd ui" - "cd ui"
- "npm ci" - "npm ci"

View File

@@ -4,6 +4,22 @@ Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## [v3.0.0-beta3]
### Added
- Docker `HEALTHCHECK`
- Mouse-out delay to improve pop-up menu navigation
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
### Changed
- Updated frontend and backend dependencies, Docker image base
### Fixed
- Improved layout on mobile and wide displays
- Prevent unexpected input for modal dialogs
- Allow empty SMTP `MAIL FROM:<>`
## [v3.0.0-beta2] ## [v3.0.0-beta2]
### Added ### Added
@@ -212,6 +228,7 @@ No change from beta1.
specific message. specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop [Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2 [v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1 [v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1 [v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1

View File

@@ -1,22 +1,31 @@
# Docker build file for Inbucket: https://www.inbucket.org/ # Docker build file for Inbucket: https://www.inbucket.org/
# Build # Install build-time dependencies
FROM golang:1.12-alpine3.10 as builder FROM golang:1.15-alpine3.12 as builder
RUN apk add --no-cache --virtual .build-deps git make npm RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
WORKDIR /build WORKDIR /build
COPY . . COPY . .
ENV CGO_ENABLED 0 ENV CGO_ENABLED 0
RUN make clean deps RUN make clean deps
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm ci
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
# Build server
WORKDIR /build
RUN go build -o inbucket \ RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \ -ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket -v ./cmd/inbucket
# Build frontend
WORKDIR /build/ui WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm i
RUN npm run build RUN npm run build
# Run in minimal image # Run in minimal image
FROM alpine:3.10 FROM alpine:3.12
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket WORKDIR /opt/inbucket
RUN mkdir bin defaults ui RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin COPY --from=builder /build/inbucket bin
@@ -36,6 +45,9 @@ ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300 ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
# Healthcheck
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
# Ports: SMTP, HTTP, POP3 # Ports: SMTP, HTTP, POP3
EXPOSE 2500 9000 1100 EXPOSE 2500 9000 1100

View File

@@ -25,6 +25,7 @@ import (
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file" "github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem" "github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui" "github.com/inbucket/inbucket/pkg/webui"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -71,6 +72,7 @@ func main() {
config.Usage() config.Usage()
return return
} }
// Process configuration. // Process configuration.
config.Version = version config.Version = version
config.BuildDate = date config.BuildDate = date
@@ -83,6 +85,7 @@ func main() {
conf.POP3.Debug = true conf.POP3.Debug = true
conf.SMTP.Debug = true conf.SMTP.Debug = true
} }
// Logger setup. // Logger setup.
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson) closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
if err != nil { if err != nil {
@@ -90,12 +93,15 @@ func main() {
os.Exit(1) os.Exit(1)
} }
startupLog := log.With().Str("phase", "startup").Logger() startupLog := log.With().Str("phase", "startup").Logger()
// Setup signal handler. // Setup signal handler.
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging. // Initialize logging.
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate). startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
Msg("Inbucket starting") Msg("Inbucket starting")
// Write pidfile if requested. // Write pidfile if requested.
if *pidfile != "" { if *pidfile != "" {
pidf, err := os.Create(*pidfile) pidf, err := os.Create(*pidfile)
@@ -107,6 +113,7 @@ func main() {
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile") startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
} }
} }
// Configure internal services. // Configure internal services.
rootCtx, rootCancel := context.WithCancel(context.Background()) rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool) shutdownChan := make(chan bool)
@@ -118,20 +125,26 @@ func main() {
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory) msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf} addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub} mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner. // Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan) retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start() retentionScanner.Start()
// Start HTTP server.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter()) // Configure routes and start HTTP server.
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub) web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx) go web.Start(rootCtx)
// Start POP3 server. // Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store) pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx) go pop3Server.Start(rootCtx)
// Start SMTP server. // Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy) smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx) go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel. // Loop forever waiting for signals or shutdown channel.
signalLoop: signalLoop:
for { for {
@@ -154,6 +167,7 @@ signalLoop:
break signalLoop break signalLoop
} }
} }
// Wait for active connections to finish. // Wait for active connections to finish.
go timedExit(*pidfile) go timedExit(*pidfile)
smtpServer.Drain() smtpServer.Drain()

View File

@@ -28,6 +28,7 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
INBUCKET_WEB_UIDIR ui/dist User interface dir INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI? INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
@@ -231,7 +232,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
### TLS Public Certificate File ### TLS Public Certificate File
`INBUCKET_SMTP_TLSPRIVKEY` `INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation. Specify the x509 Certificate file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled. This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
@@ -290,6 +291,24 @@ Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:9000` - Default: `0.0.0.0:9000`
### Base Path
`INBUCKET_WEB_BASEPATH`
Base path prefix for UI and API URLs. This option is used when you wish to
root all Inbucket URLs to a specific path when placing it behind a
reverse-proxy.
For example, setting the base path to `prefix` will move:
- the Inbucket status page from `/status` to `/prefix/status`,
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
*Note:* This setting will not work correctly when running Inbucket via the npm
development server.
- Default: None
### UI Directory ### UI Directory
`INBUCKET_WEB_UIDIR` `INBUCKET_WEB_UIDIR`

View File

@@ -13,6 +13,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist" export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false" #export INBUCKET_WEB_MONITORVISIBLE="false"
#export INBUCKET_WEB_BASEPATH="prefix"
export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h" export INBUCKET_STORAGE_RETENTIONPERIOD="3h"

View File

@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image # description: Launch Inbucket's docker image
# Docker Image Tag # Docker Image Tag
IMAGE="jhillyerd/inbucket" IMAGE="inbucket/inbucket"
# Ports exposed on host: # Ports exposed on host:
PORT_HTTP=9000 PORT_HTTP=9000

30
go.mod
View File

@@ -2,20 +2,24 @@ module github.com/inbucket/inbucket
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-test/deep v1.0.2 // indirect github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
github.com/google/subcommands v1.0.1 github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0 github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.0 github.com/gorilla/websocket v1.4.2
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 // indirect github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/jhillyerd/enmime v0.6.0 github.com/jhillyerd/enmime v0.8.1
github.com/jhillyerd/goldiff v0.1.0 github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.4 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/microcosm-cc/bluemonday v1.0.2 github.com/microcosm-cc/bluemonday v1.0.4
github.com/olekukonko/tablewriter v0.0.1 // indirect github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/rs/zerolog v1.15.0 github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.3.0 github.com/rs/zerolog v1.19.0
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 github.com/stretchr/testify v1.6.1
golang.org/x/text v0.3.2 // indirect golang.org/x/net v0.0.0-20200822124328-c89045814202
golang.org/x/text v0.3.3 // indirect
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
) )
go 1.13

75
go.sum
View File

@@ -1,68 +1,89 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs= github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE= github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.6.0 h1:FeypffI/uD1xt+Csd7gfD7mYx1h+qjgGlcI/ko5+LsI= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jhillyerd/enmime v0.6.0/go.mod h1:lwWyVhHVBdmzXx3wtRTmpIdNEJyZ85LJuVqZHVK/Rlo= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.8.1 h1:Kz4xj3sJJ4Ju8e+w/7v9H4Matv5ijPgv7UkhPf+C15I=
github.com/jhillyerd/enmime v0.8.1/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A= github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs= github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88= github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= github.com/rs/zerolog v1.19.0 h1:hYz4ZVdUgjXTBUmrkrw55j1nHx68LfOKIQk5IYtyScg=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rs/zerolog v1.19.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -96,6 +96,7 @@ type POP3 struct {
// Web contains the HTTP server configuration. // Web contains the HTTP server configuration.
type Web struct { type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"` Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"` UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"` GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"` MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`

View File

@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
} }
break break
} }
// not an EOF // not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err) ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok { if netErr, ok := err.(net.Error); ok {

View File

@@ -41,7 +41,7 @@ const (
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive, // accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match // (?:) is non-grouping sub-match
var fromRegex = regexp.MustCompile( var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$") "(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
func (s State) String() string { func (s State) String() string {
switch s { switch s {
@@ -314,11 +314,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
return return
} }
from := m[1] from := m[1]
if _, _, err := policy.ParseEmailAddress(from); err != nil { if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
s.send("501 Bad sender address syntax") s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err) s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return return
} }
if from == "" {
from = "unspecified"
}
// This is where the client may put BODY=8BITMIME, but we already // This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing. // read the DATA as bytes, so it does not effect our processing.
if m[2] != "" { if m[2] != "" {
@@ -433,6 +437,7 @@ func (s *Session) dataHandler() {
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n", 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, s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp) tstamp)
// Deliver message. // Deliver message.
_, err := s.manager.Deliver( _, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes()) recip, s.from, s.recipients, prefix, mailData.Bytes())
@@ -527,12 +532,14 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
s.logger.Warn().Msgf("Mangled command: %q", line) s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false return "", "", false
} }
// If we made it here, command is long enough to have args // If we made it here, command is long enough to have args
if line[4] != ' ' { if line[4] != ' ' {
// There wasn't a space after the command? // There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line) s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false return "", "", false
} }
// I'm not sure if we should trim the args or not, but we will for now // 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 return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
} }

View File

@@ -79,6 +79,35 @@ func TestGreetState(t *testing.T) {
} }
} }
// Test commands in READY state
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
}
// Test commands in READY state // Test commands in READY state
func TestReadyState(t *testing.T) { func TestReadyState(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()

View File

@@ -1,5 +1,6 @@
package web package web
type jsonAppConfig struct { type jsonAppConfig struct {
MonitorVisible bool `json:"monitor-visible"` BasePath string `json:"base-path"`
MonitorVisible bool `json:"monitor-visible"`
} }

View File

@@ -1,9 +1,11 @@
package web package web
import ( import (
"html/template"
"net/http" "net/http"
"os" "os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -82,3 +84,21 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
}) })
} }
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
func spaTemplateHandler(tmpl *template.Template, basePath string,
webConfig config.Web) http.Handler {
tmplData := struct {
BasePath string
}{
BasePath: basePath,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
err := tmpl.Execute(w, tmplData)
if err != nil {
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
Msg("Error rendering SPA index template")
}
})
}

View File

@@ -5,10 +5,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"expvar" "expvar"
"html/template"
"net" "net"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -16,6 +18,7 @@ import (
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub" "github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -56,33 +59,59 @@ func Initialize(
msgHub = mh msgHub = mh
manager = mm manager = mm
// Redirect requests to / if there is a base path configured.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
redirectBase := prefix("/")
if redirectBase != "/" {
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
Msg("Base path configured")
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
}
// Dynamic paths. // Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped") Msg("Web UI content mapped")
Router.Handle("/debug/vars", expvar.Handler()) Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf { if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile) Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace) Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup"). log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to /debug/pprof") Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
} }
// Static paths. // Static paths.
Router.PathPrefix("/static").Handler( Router.PathPrefix(prefix("/static")).Handler(
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir)))) http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path("/favicon.png").Handler( Router.Path(prefix("/favicon.png")).Handler(
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png"))) fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
// Parse index.html template, allowing for configuration to be passed to the SPA.
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
indexTmpl, err := template.ParseFiles(indexPath)
if err != nil {
msg := "Failed to parse HTML template"
cwd, _ := os.Getwd()
log.Error().
Str("module", "web").
Str("phase", "startup").
Str("path", indexPath).
Str("cwd", cwd).
Err(err).
Msg(msg)
// Create a dummy template to allow tests to pass.
indexTmpl, _ = template.New("index.html").Parse(msg)
}
// SPA managed paths. // SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web), spaHandler := cookieHandler(appConfigCookie(conf.Web),
fileHandler(filepath.Join(conf.Web.UIDir, "index.html"))) spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
Router.Path("/").Handler(spaHandler) Router.Path(prefix("/")).Handler(spaHandler)
Router.Path("/monitor").Handler(spaHandler) Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path("/status").Handler(spaHandler) Router.Path(prefix("/status")).Handler(spaHandler)
Router.PathPrefix("/m/").Handler(spaHandler) Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
// Error handlers. // Error handlers.
Router.NotFoundHandler = noMatchHandler( Router.NotFoundHandler = noMatchHandler(
@@ -131,6 +160,7 @@ func Start(ctx context.Context) {
func appConfigCookie(webConfig config.Web) *http.Cookie { func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{ o := &jsonAppConfig{
BasePath: webConfig.BasePath,
MonitorVisible: webConfig.MonitorVisible, MonitorVisible: webConfig.MonitorVisible,
} }
b, err := json.Marshal(o) b, err := json.Marshal(o)

View File

@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
slice[i] = strings.ToLower(s) slice[i] = strings.ToLower(s)
} }
} }
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
// The returned prefixer expects all provided paths to start with /.
func MakePathPrefixer(prefix string) func(string) string {
prefix = strings.Trim(prefix, "/")
if prefix != "" {
prefix = "/" + prefix
}
return func(path string) string {
return prefix + path
}
}

View File

@@ -1,6 +1,7 @@
package stringutil_test package stringutil_test
import ( import (
"fmt"
"net/mail" "net/mail"
"testing" "testing"
@@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
} }
} }
} }
func TestMakePathPrefixer(t *testing.T) {
testCases := []struct {
prefix, path, want string
}{
{prefix: "", path: "", want: ""},
{prefix: "", path: "relative", want: "relative"},
{prefix: "", path: "/qualified", want: "/qualified"},
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
{prefix: "pfx", path: "", want: "/pfx"},
{prefix: "pfx", path: "/", want: "/pfx/"},
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
{prefix: "/pfx/", path: "", want: "/pfx"},
{prefix: "/pfx/", path: "/", want: "/pfx/"},
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
{prefix: "a/b/c", path: "", want: "/a/b/c"},
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
prefixer := stringutil.MakePathPrefixer(tc.prefix)
got := prefixer(tc.path)
if got != tc.want {
t.Errorf("Got: %q, want: %q", got, tc.want)
}
})
}
}

View File

@@ -5,7 +5,10 @@ stdenv.mkDerivation rec {
buildInputs = [ buildInputs = [
dpkg dpkg
elmPackages.elm elmPackages.elm
elmPackages.elm-analyse
elmPackages.elm-format elmPackages.elm-format
elmPackages.elm-language-server
elmPackages.elm-test
go go
golint golint
nodejs-10_x nodejs-10_x

View File

@@ -3,25 +3,25 @@
"source-directories": [ "source-directories": [
"src" "src"
], ],
"elm-version": "0.19.0", "elm-version": "0.19.1",
"dependencies": { "dependencies": {
"direct": { "direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.0", "NoRedInk/elm-json-decode-pipeline": "1.0.0",
"basti1302/elm-human-readable-filesize": "1.1.1", "basti1302/elm-human-readable-filesize": "1.2.0",
"elm/browser": "1.0.1", "elm/browser": "1.0.2",
"elm/core": "1.0.2", "elm/core": "1.0.5",
"elm/html": "1.0.0", "elm/html": "1.0.0",
"elm/http": "2.0.0", "elm/http": "2.0.0",
"elm/json": "1.1.2", "elm/json": "1.1.3",
"elm/svg": "1.0.1", "elm/svg": "1.0.1",
"elm/time": "1.0.0", "elm/time": "1.0.0",
"elm/url": "1.0.0", "elm/url": "1.0.0",
"jweir/sparkline": "4.0.0", "jweir/sparkline": "4.0.0",
"ryannhg/date-format": "2.1.0" "ryannhg/date-format": "2.3.0"
}, },
"indirect": { "indirect": {
"elm/bytes": "1.0.3", "elm/bytes": "1.0.8",
"elm/file": "1.0.1", "elm/file": "1.0.5",
"elm/regex": "1.0.0", "elm/regex": "1.0.0",
"elm/virtual-dom": "1.0.2", "elm/virtual-dom": "1.0.2",
"myrho/elm-round": "1.0.4" "myrho/elm-round": "1.0.4"
@@ -31,4 +31,4 @@
"direct": {}, "direct": {},
"indirect": {} "indirect": {}
} }
} }

5133
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,21 +11,20 @@
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.5.5", "@babel/core": "^7.11.4",
"@babel/preset-env": "^7.5.5", "@babel/preset-env": "^7.11.0",
"@fortawesome/fontawesome-free": "^5.10.1", "@fortawesome/fontawesome-free": "^5.14.0",
"@webcomponents/webcomponentsjs": "^2.2.10", "@webcomponents/webcomponentsjs": "^2.4.4",
"babel-loader": "^8.0.6", "babel-loader": "^8.1.0",
"css-loader": "^1.0.1", "css-loader": "^4.2.2",
"elm": "^0.19.0-no-deps", "elm-hot-webpack-loader": "^1.1.7",
"elm-hot-webpack-loader": "^1.1.1", "elm-webpack-loader": "^7.0.1",
"elm-webpack-loader": "^6.0.0", "file-loader": "^6.0.0",
"file-loader": "^3.0.1", "html-webpack-plugin": "^4.4.1",
"html-webpack-plugin": "^3.2.0", "node-elm-compiler": "^5.0.5",
"node-elm-compiler": "^5.0.4", "style-loader": "^1.2.1",
"style-loader": "^0.23.1", "webpack": "^4.44.1",
"webpack": "^4.39.1", "webpack-cli": "^3.3.12",
"webpack-cli": "^3.3.6", "webpack-dev-server": "^3.11.0"
"webpack-dev-server": "^3.8.0"
} }
} }

21
ui/public/index-dev.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- This index file will be served by the webpack development server. -->
<base href="/">
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Inbucket</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<base href="{{ .BasePath }}">
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

View File

@@ -6,6 +6,7 @@ module Api exposing
, getServerConfig , getServerConfig
, getServerMetrics , getServerMetrics
, markMessageSeen , markMessageSeen
, monitorUri
, purgeMailbox , purgeMailbox
, serveUrl , serveUrl
) )
@@ -14,10 +15,12 @@ import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Metrics as Metrics exposing (Metrics) import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig) import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import Http import Http
import HttpUtil import HttpUtil
import Json.Decode as Decode import Json.Decode as Decode
import Json.Encode as Encode import Json.Encode as Encode
import String
import Url.Builder import Url.Builder
@@ -29,31 +32,17 @@ type alias HttpResult msg =
Result HttpUtil.Error () -> msg Result HttpUtil.Error () -> msg
{-| Builds a public REST API URL (see wiki). deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
-} deleteMessage session msg mailboxName id =
apiV1Url : List String -> String HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
apiV1Url elements =
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
{-| Builds an internal `serve` REST API URL; only used by this UI. getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
-} getHeaderList session msg mailboxName =
serveUrl : List String -> String
serveUrl elements =
Url.Builder.absolute ([ "serve" ] ++ elements) []
deleteMessage : HttpResult msg -> String -> String -> Cmd msg
deleteMessage msg mailboxName id =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName, id ])
getHeaderList : DataResult msg (List MessageHeader) -> String -> Cmd msg
getHeaderList msg mailboxName =
let let
context = context =
{ method = "GET" { method = "GET"
, url = apiV1Url [ "mailbox", mailboxName ] , url = apiV1Url session [ "mailbox", mailboxName ]
} }
in in
Http.get Http.get
@@ -62,12 +51,12 @@ getHeaderList msg mailboxName =
} }
getGreeting : DataResult msg String -> Cmd msg getGreeting : Session -> DataResult msg String -> Cmd msg
getGreeting msg = getGreeting session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "greeting" ] , url = serveUrl session [ "greeting" ]
} }
in in
Http.get Http.get
@@ -76,12 +65,12 @@ getGreeting msg =
} }
getMessage : DataResult msg Message -> String -> String -> Cmd msg getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
getMessage msg mailboxName id = getMessage session msg mailboxName id =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "mailbox", mailboxName, id ] , url = serveUrl session [ "mailbox", mailboxName, id ]
} }
in in
Http.get Http.get
@@ -90,12 +79,12 @@ getMessage msg mailboxName id =
} }
getServerConfig : DataResult msg ServerConfig -> Cmd msg getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
getServerConfig msg = getServerConfig session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "status" ] , url = serveUrl session [ "status" ]
} }
in in
Http.get Http.get
@@ -104,12 +93,19 @@ getServerConfig msg =
} }
getServerMetrics : DataResult msg Metrics -> Cmd msg getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
getServerMetrics msg = getServerMetrics session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = Url.Builder.absolute [ "debug", "vars" ] [] , url =
Url.Builder.absolute
(splitBasePath session.config.basePath
++ [ "debug"
, "vars"
]
)
[]
} }
in in
Http.get Http.get
@@ -118,15 +114,73 @@ getServerMetrics msg =
} }
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
markMessageSeen msg mailboxName id = markMessageSeen session msg mailboxName id =
-- The URL tells the API which message ID to update, so we only need to indicate the -- The URL tells the API which message ID to update, so we only need to indicate the
-- desired change in the body. -- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ] Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody |> Http.jsonBody
|> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ]) |> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
purgeMailbox : HttpResult msg -> String -> Cmd msg monitorUri : Session -> String
purgeMailbox msg mailboxName = monitorUri session =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ]) apiV1Url session [ "monitor", "messages" ]
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
purgeMailbox session msg mailboxName =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
{-| Builds a public REST API URL (see wiki).
-}
apiV1Url : Session -> List String -> String
apiV1Url session elements =
Url.Builder.absolute
(List.concat
[ splitBasePath session.config.basePath
, [ "api", "v1" ]
, elements
]
)
[]
{-| Builds an internal `serve` REST API URL; only used by this UI.
-}
serveUrl : Session -> List String -> String
serveUrl session elements =
Url.Builder.absolute
(List.concat
[ splitBasePath session.config.basePath
, [ "serve" ]
, elements
]
)
[]
{-| Converts base path into a list of path elements.
-}
splitBasePath : String -> List String
splitBasePath path =
if path == "" then
[]
else
let
stripSlashes str =
if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
else if String.endsWith "/" str then
stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
String.split "/" newPath

View File

@@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P
type alias AppConfig = type alias AppConfig =
{ monitorVisible : Bool { basePath : String
, monitorVisible : Bool
} }
decoder : D.Decoder AppConfig decoder : D.Decoder AppConfig
decoder = decoder =
D.succeed AppConfig D.succeed AppConfig
|> P.optional "base-path" D.string ""
|> P.required "monitor-visible" D.bool |> P.required "monitor-visible" D.bool
default : AppConfig default : AppConfig
default = default =
AppConfig True AppConfig "" True

View File

@@ -1,6 +1,6 @@
module Data.Date exposing (date) module Data.Date exposing (date)
import Json.Decode exposing (..) import Json.Decode exposing (Decoder, int, map)
import Time exposing (Posix) import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder) module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
import Data.Date exposing (date) import Data.Date exposing (date)
import Json.Decode exposing (..) import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix) import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.MessageHeader exposing (MessageHeader, decoder) module Data.MessageHeader exposing (MessageHeader, decoder)
import Data.Date exposing (date) import Data.Date exposing (date)
import Json.Decode exposing (..) import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix) import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.Metrics exposing (Metrics, decodeIntList, decoder) module Data.Metrics exposing (Metrics, decodeIntList, decoder)
import Data.Date exposing (date) import Data.Date exposing (date)
import Json.Decode as Decode exposing (..) import Json.Decode exposing (Decoder, int, map, string, succeed)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (requiredAt)
import Time exposing (Posix) import Time exposing (Posix)

View File

@@ -15,11 +15,10 @@ module Data.Session exposing
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig) import Data.AppConfig as AppConfig exposing (AppConfig)
import Html exposing (Html)
import Json.Decode as D import Json.Decode as D
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (optional)
import Json.Encode as E import Json.Encode as E
import Ports import Route exposing (Router)
import Time import Time
import Url exposing (Url) import Url exposing (Url)
@@ -29,6 +28,7 @@ type alias Session =
, host : String , host : String
, flash : Maybe Flash , flash : Maybe Flash
, routing : Bool , routing : Bool
, router : Router
, zone : Time.Zone , zone : Time.Zone
, config : AppConfig , config : AppConfig
, persistent : Persistent , persistent : Persistent
@@ -52,6 +52,7 @@ init key location config persistent =
, host = location.host , host = location.host
, flash = Nothing , flash = Nothing
, routing = True , routing = True
, router = Route.newRouter config.basePath
, zone = Time.utc , zone = Time.utc
, config = config , config = config
, persistent = persistent , persistent = persistent
@@ -64,6 +65,7 @@ initError key location error =
, host = location.host , host = location.host
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ]) , flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
, routing = True , routing = True
, router = Route.newRouter ""
, zone = Time.utc , zone = Time.utc
, config = AppConfig.default , config = AppConfig.default
, persistent = Persistent [] , persistent = Persistent []

View File

@@ -1,7 +1,6 @@
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch) module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
import Data.Session as Session import Data.Session as Session
import Html exposing (Html, div, text)
import Http import Http
import Json.Decode as Decode import Json.Decode as Decode

View File

@@ -1,23 +1,46 @@
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update) module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
import Browser.Navigation as Nav
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Html exposing (..) import Html
exposing
( Attribute
, Html
, a
, button
, div
, footer
, form
, h2
, header
, i
, input
, li
, nav
, pre
, span
, td
, text
, th
, tr
, ul
)
import Html.Attributes import Html.Attributes
exposing exposing
( attribute ( attribute
, class , class
, classList , classList
, href , href
, id
, placeholder , placeholder
, rel , rel
, selected
, target , target
, type_ , type_
, value , value
) )
import Html.Events as Events import Html.Events as Events
import Modal
import Route exposing (Route) import Route exposing (Route)
import Timer exposing (Timer)
{-| Used to highlight current page in navbar. {-| Used to highlight current page in navbar.
@@ -31,8 +54,9 @@ type Page
type alias Model msg = type alias Model msg =
{ mapMsg : Msg -> msg { mapMsg : Msg -> msg
, menuVisible : Bool , mainMenuVisible : Bool
, recentVisible : Bool , recentMenuVisible : Bool
, recentMenuTimer : Timer
, mailboxName : String , mailboxName : String
} }
@@ -40,8 +64,9 @@ type alias Model msg =
init : (Msg -> msg) -> Model msg init : (Msg -> msg) -> Model msg
init mapMsg = init mapMsg =
{ mapMsg = mapMsg { mapMsg = mapMsg
, menuVisible = False , mainMenuVisible = False
, recentVisible = False , recentMenuVisible = False
, recentMenuTimer = Timer.empty
, mailboxName = "" , mailboxName = ""
} }
@@ -51,18 +76,24 @@ init mapMsg =
reset : Model msg -> Model msg reset : Model msg -> Model msg
reset model = reset model =
{ model { model
| menuVisible = False | mainMenuVisible = False
, recentVisible = False , recentMenuVisible = False
, recentMenuTimer = Timer.cancel model.recentMenuTimer
, mailboxName = "" , mailboxName = ""
} }
type Msg type Msg
= ClearFlash = ClearFlash
| MainMenuToggled
| ModalFocused Modal.Msg
| ModalUnfocused
| OnMailboxNameInput String | OnMailboxNameInput String
| OpenMailbox | OpenMailbox
| ShowRecent Bool | RecentMenuMouseOver
| ToggleMenu | RecentMenuMouseOut
| RecentMenuTimeout Timer
| RecentMenuToggled
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg ) update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
@@ -74,6 +105,21 @@ update msg model session =
, Cmd.none , Cmd.none
) )
MainMenuToggled ->
( { model | mainMenuVisible = not model.mainMenuVisible }
, session
, Cmd.none
)
ModalFocused message ->
( model
, Modal.updateSession message session
, Cmd.none
)
ModalUnfocused ->
( model, session, Modal.resetFocusCmd (ModalFocused >> model.mapMsg) )
OnMailboxNameInput name -> OnMailboxNameInput name ->
( { model | mailboxName = name } ( { model | mailboxName = name }
, session , session
@@ -87,17 +133,48 @@ update msg model session =
else else
( model ( model
, session , session
, Route.pushUrl session.key (Route.Mailbox model.mailboxName) , Route.Mailbox model.mailboxName
|> session.router.toPath
|> Nav.pushUrl session.key
) )
ShowRecent visible -> RecentMenuMouseOver ->
( { model | recentVisible = visible } ( { model
| recentMenuVisible = True
, recentMenuTimer = Timer.cancel model.recentMenuTimer
}
, session , session
, Cmd.none , Cmd.none
) )
ToggleMenu -> RecentMenuMouseOut ->
( { model | menuVisible = not model.menuVisible } let
newTimer =
Timer.replace model.recentMenuTimer
in
( { model
| recentMenuTimer = newTimer
}
, session
, Timer.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
)
RecentMenuTimeout timer ->
if timer == model.recentMenuTimer then
( { model
| recentMenuVisible = False
, recentMenuTimer = Timer.cancel timer
}
, session
, Cmd.none
)
else
-- Timer was no longer valid.
( model, session, Cmd.none )
RecentMenuToggled ->
( { model | recentMenuVisible = not model.recentMenuVisible }
, session , session
, Cmd.none , Cmd.none
) )
@@ -118,17 +195,17 @@ frame { model, session, activePage, activeMailbox, modal, content } =
div [ class "app" ] div [ class "app" ]
[ header [] [ header []
[ nav [ class "navbar" ] [ nav [ class "navbar" ]
[ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> model.mapMsg) ] [ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
[ i [ class "fas fa-bars" ] [] ] [ i [ class "fas fa-bars" ] [] ]
, span [ class "navbar-brand" ] , span [ class "navbar-brand" ]
[ a [ Route.href Route.Home ] [ text "@ inbucket" ] ] [ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ] , ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
[ if session.config.monitorVisible then [ if session.config.monitorVisible then
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
else else
text "" text ""
, navbarLink Status Route.Status [ text "Status" ] activePage , navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
, navbarRecent activePage activeMailbox model session , navbarRecent activePage activeMailbox model session
, li [ class "navbar-mailbox" ] , li [ class "navbar-mailbox" ]
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ] [ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
@@ -145,8 +222,8 @@ frame { model, session, activePage, activeMailbox, modal, content } =
] ]
] ]
, div [ class "navbar-bg" ] [ text "" ] , div [ class "navbar-bg" ] [ text "" ]
, frameModal modal , Modal.view (ModalUnfocused |> model.mapMsg) modal
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content) , div [ class "page" ] (errorFlash model session.flash :: content)
, footer [] , footer []
[ div [ class "footer" ] [ div [ class "footer" ]
[ externalLink "https://www.inbucket.org" "Inbucket" [ externalLink "https://www.inbucket.org" "Inbucket"
@@ -158,18 +235,6 @@ frame { model, session, activePage, activeMailbox, modal, content } =
] ]
frameModal : Maybe (Html msg) -> Html msg
frameModal maybeModal =
case maybeModal of
Just modal ->
div [ class "modal-mask" ]
[ div [ class "modal well" ] [ modal ]
]
Nothing ->
text ""
errorFlash : Model msg -> Maybe Session.Flash -> Html msg errorFlash : Model msg -> Maybe Session.Flash -> Html msg
errorFlash model maybeFlash = errorFlash model maybeFlash =
let let
@@ -198,10 +263,10 @@ externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ] a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page route linkContent activePage = navbarLink page url linkContent activePage =
li [ classList [ ( "navbar-active", page == activePage ) ] ] li [ classList [ ( "navbar-active", page == activePage ) ] ]
[ a [ Route.href route ] linkContent ] [ a [ href url ] linkContent ]
{-| Renders list of recent mailboxes, selecting the currently active mailbox. {-| Renders list of recent mailboxes, selecting the currently active mailbox.
@@ -229,29 +294,22 @@ navbarRecent page activeMailbox model session =
else else
session.persistent.recentMailboxes session.persistent.recentMailboxes
dropdownExpanded =
if model.recentVisible then
"true"
else
"false"
recentLink mailbox = recentLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ] a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
in in
li li
[ class "navbar-dropdown-container" [ class "navbar-dropdown-container"
, classList [ ( "navbar-active", active ) ] , classList [ ( "navbar-active", active ) ]
, attribute "aria-haspopup" "true" , attribute "aria-haspopup" "true"
, ariaExpanded model.recentVisible , ariaExpanded model.recentMenuVisible
, Events.onMouseOver (ShowRecent True |> model.mapMsg) , Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
, Events.onMouseOut (ShowRecent False |> model.mapMsg) , Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
] ]
[ span [ class "navbar-dropdown" ] [ span [ class "navbar-dropdown" ]
[ text title [ text title
, button , button
[ class "navbar-dropdown-button" [ class "navbar-dropdown-button"
, Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg) , Events.onClick (RecentMenuToggled |> model.mapMsg)
] ]
[ i [ class "fas fa-chevron-down" ] [] ] [ i [ class "fas fa-chevron-down" ] [] ]
] ]

View File

@@ -4,7 +4,7 @@ import Browser exposing (Document, UrlRequest)
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig) import Data.AppConfig as AppConfig exposing (AppConfig)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Html exposing (..) import Html exposing (Html)
import Json.Decode as D exposing (Value) import Json.Decode as D exposing (Value)
import Layout import Layout
import Page.Home as Home import Page.Home as Home
@@ -66,7 +66,7 @@ init configValue location key =
} }
route = route =
Route.fromUrl location session.router.fromUrl location
( model, cmd ) = ( model, cmd ) =
changeRouteTo route initModel changeRouteTo route initModel
@@ -167,7 +167,7 @@ updateMain msg model session =
UrlChanged url -> UrlChanged url ->
-- Responds to new browser URL. -- Responds to new browser URL.
if session.routing then if session.routing then
changeRouteTo (Route.fromUrl url) model changeRouteTo (session.router.fromUrl url) model
else else
-- Skip once, but re-enable routing. -- Skip once, but re-enable routing.

58
ui/src/Modal.elm Normal file
View File

@@ -0,0 +1,58 @@
module Modal exposing (Msg, resetFocusCmd, updateSession, view)
import Browser.Dom as Dom
import Data.Session as Session exposing (Session)
import Html exposing (Html, div, span, text)
import Html.Attributes exposing (class, id, tabindex)
import Html.Events exposing (onFocus)
import Task
type alias Msg =
Result Dom.Error ()
{-| Creates a command to focus the modal dialog.
-}
resetFocusCmd : (Msg -> msg) -> Cmd msg
resetFocusCmd resultMsg =
Task.attempt resultMsg (Dom.focus domId)
{-| Updates a Session with an error Flash if the resetFocusCmd failed.
-}
updateSession : Msg -> Session -> Session
updateSession result session =
case result of
Ok () ->
session
Err (Dom.NotFound missingDomId) ->
let
flash =
{ title = "DOM element not found"
, table = [ ( "Element ID", missingDomId ) ]
}
in
Session.showFlash flash session
view : msg -> Maybe (Html msg) -> Html msg
view unfocusedMsg maybeModal =
case maybeModal of
Just modal ->
div [ class "modal-mask" ]
[ span [ onFocus unfocusedMsg, tabindex 0 ] []
, div [ id domId, class "modal well", tabindex -1 ] [ modal ]
, span [ onFocus unfocusedMsg, tabindex 0 ] []
]
Nothing ->
text ""
{-| DOM ID of the modal dialog.
-}
domId : String
domId =
"modal-dialog"

View File

@@ -2,12 +2,10 @@ module Page.Home exposing (Model, Msg, init, update, view)
import Api import Api
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Html exposing (..) import Html exposing (Html)
import Html.Attributes exposing (..) import Html.Attributes exposing (class, property)
import Http
import HttpUtil import HttpUtil
import Json.Encode as Encode import Json.Encode as Encode
import Ports
@@ -22,7 +20,7 @@ type alias Model =
init : Session -> ( Model, Cmd Msg ) init : Session -> ( Model, Cmd Msg )
init session = init session =
( Model session "", Api.getGreeting GreetingLoaded ) ( Model session "", Api.getGreeting session GreetingLoaded )

View File

@@ -1,12 +1,38 @@
module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view) module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view)
import Api import Api
import Browser.Navigation as Nav
import Data.Message as Message exposing (Message) import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import DateFormat as DF import DateFormat as DF
import DateFormat.Relative as Relative import DateFormat.Relative as Relative
import Html exposing (..) import Html
exposing
( Attribute
, Html
, a
, article
, aside
, button
, dd
, div
, dl
, dt
, h3
, i
, input
, li
, main_
, nav
, p
, span
, table
, td
, text
, tr
, ul
)
import Html.Attributes import Html.Attributes
exposing exposing
( alt ( alt
@@ -15,7 +41,6 @@ import Html.Attributes
, disabled , disabled
, download , download
, href , href
, id
, placeholder , placeholder
, property , property
, tabindex , tabindex
@@ -24,14 +49,14 @@ import Html.Attributes
, value , value
) )
import Html.Events as Events import Html.Events as Events
import Http exposing (Error)
import HttpUtil import HttpUtil
import Json.Decode as D import Json.Decode as D
import Json.Encode as E import Json.Encode as E
import Ports import Modal
import Route import Route
import Task import Task
import Time exposing (Posix) import Time exposing (Posix)
import Timer exposing (Timer)
@@ -51,8 +76,8 @@ type State
type MessageState type MessageState
= NoMessage = NoMessage
| LoadingMessage | LoadingMessage
| ShowingMessage VisibleMessage | ShowingMessage Message
| Transitioning VisibleMessage | Transitioning Message
type alias MessageID = type alias MessageID =
@@ -66,12 +91,6 @@ type alias MessageList =
} }
type alias VisibleMessage =
{ message : Message
, markSeenAt : Maybe Int
}
type alias Model = type alias Model =
{ session : Session { session : Session
, mailboxName : String , mailboxName : String
@@ -79,6 +98,7 @@ type alias Model =
, bodyMode : Body , bodyMode : Body
, searchInput : String , searchInput : String
, promptPurge : Bool , promptPurge : Bool
, markSeenTimer : Timer
, now : Posix , now : Posix
} }
@@ -91,17 +111,18 @@ init session mailboxName selection =
, bodyMode = SafeHtmlBody , bodyMode = SafeHtmlBody
, searchInput = "" , searchInput = ""
, promptPurge = False , promptPurge = False
, markSeenTimer = Timer.empty
, now = Time.millisToPosix 0 , now = Time.millisToPosix 0
} }
, load mailboxName , load session mailboxName
) )
load : String -> Cmd Msg load : Session -> String -> Cmd Msg
load mailboxName = load session mailboxName =
Cmd.batch Cmd.batch
[ Task.perform Tick Time.now [ Task.perform Tick Time.now
, Api.getHeaderList ListLoaded mailboxName , Api.getHeaderList session ListLoaded mailboxName
] ]
@@ -110,24 +131,8 @@ load mailboxName =
subscriptions : Model -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions model = subscriptions _ =
let Time.every (30 * 1000) Tick
subSeen =
case model.state of
ShowingList _ (ShowingMessage { message }) ->
if message.seen then
Sub.none
else
Time.every 250 MarkSeenTick
_ ->
Sub.none
in
Sub.batch
[ Time.every (30 * 1000) Tick
, subSeen
]
@@ -138,13 +143,11 @@ type Msg
= ListLoaded (Result HttpUtil.Error (List MessageHeader)) = ListLoaded (Result HttpUtil.Error (List MessageHeader))
| ClickMessage MessageID | ClickMessage MessageID
| ListKeyPress String Int | ListKeyPress String Int
| OpenMessage MessageID
| CloseMessage | CloseMessage
| MessageLoaded (Result HttpUtil.Error Message) | MessageLoaded (Result HttpUtil.Error Message)
| MessageBody Body | MessageBody Body
| OpenedTime Posix | MarkSeenTriggered Timer
| MarkSeenTick Posix | MarkSeenLoaded (Result HttpUtil.Error ())
| MarkedSeen (Result HttpUtil.Error ())
| DeleteMessage Message | DeleteMessage Message
| DeletedMessage (Result HttpUtil.Error ()) | DeletedMessage (Result HttpUtil.Error ())
| PurgeMailboxPrompt | PurgeMailboxPrompt
@@ -153,6 +156,7 @@ type Msg
| PurgedMailbox (Result HttpUtil.Error ()) | PurgedMailbox (Result HttpUtil.Error ())
| OnSearchInput String | OnSearchInput String
| Tick Posix | Tick Posix
| ModalFocused Modal.Msg
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
@@ -162,14 +166,13 @@ update msg model =
( updateSelected { model | session = Session.disableRouting model.session } id ( updateSelected { model | session = Session.disableRouting model.session } id
, Cmd.batch , Cmd.batch
[ -- Update browser location. [ -- Update browser location.
Route.replaceUrl model.session.key (Route.Message model.mailboxName id) Route.Message model.mailboxName id
, Api.getMessage MessageLoaded model.mailboxName id |> model.session.router.toPath
|> Nav.replaceUrl model.session.key
, Api.getMessage model.session MessageLoaded model.mailboxName id
] ]
) )
OpenMessage id ->
updateOpenMessage model id
CloseMessage -> CloseMessage ->
case model.state of case model.state of
ShowingList list _ -> ShowingList list _ ->
@@ -225,10 +228,10 @@ update msg model =
, Cmd.none , Cmd.none
) )
MarkedSeen (Ok _) -> MarkSeenLoaded (Ok _) ->
( model, Cmd.none ) ( model, Cmd.none )
MarkedSeen (Err err) -> MarkSeenLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session } ( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none , Cmd.none
) )
@@ -244,44 +247,22 @@ update msg model =
MessageBody bodyMode -> MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none ) ( { model | bodyMode = bodyMode }, Cmd.none )
ModalFocused message ->
( { model | session = Modal.updateSession message model.session }
, Cmd.none
)
OnSearchInput searchInput -> OnSearchInput searchInput ->
updateSearchInput model searchInput updateSearchInput model searchInput
OpenedTime time ->
case model.state of
ShowingList list (ShowingMessage visible) ->
if visible.message.seen then
( model, Cmd.none )
else
-- Set 1500ms delay before reporting message as seen to backend.
let
markSeenAt =
Time.posixToMillis time + 1500
in
( { model
| state =
ShowingList list
(ShowingMessage
{ visible
| markSeenAt = Just markSeenAt
}
)
}
, Cmd.none
)
_ ->
( model, Cmd.none )
PurgeMailboxPrompt -> PurgeMailboxPrompt ->
( { model | promptPurge = True }, Cmd.none ) ( { model | promptPurge = True }, Modal.resetFocusCmd ModalFocused )
PurgeMailboxCanceled -> PurgeMailboxCanceled ->
( { model | promptPurge = False }, Cmd.none ) ( { model | promptPurge = False }, Cmd.none )
PurgeMailboxConfirmed -> PurgeMailboxConfirmed ->
updatePurge model updateTriggerPurge model
PurgedMailbox (Ok _) -> PurgedMailbox (Ok _) ->
( model, Cmd.none ) ( model, Cmd.none )
@@ -291,22 +272,13 @@ update msg model =
, Cmd.none , Cmd.none
) )
MarkSeenTick now -> MarkSeenTriggered timer ->
case model.state of if timer == model.markSeenTimer then
ShowingList _ (ShowingMessage { message, markSeenAt }) -> -- Matching timer means we have changed messages, mark this one seen.
case markSeenAt of updateMarkMessageSeen model
Just deadline ->
if Time.posixToMillis now >= deadline then
updateMarkMessageSeen model message
else else
( model, Cmd.none ) ( model, Cmd.none )
Nothing ->
( model, Cmd.none )
_ ->
( model, Cmd.none )
Tick now -> Tick now ->
( { model | now = now }, Cmd.none ) ( { model | now = now }, Cmd.none )
@@ -329,28 +301,38 @@ updateMessageResult model message =
( model, Cmd.none ) ( model, Cmd.none )
ShowingList list _ -> ShowingList list _ ->
let
newTimer =
Timer.replace model.markSeenTimer
in
( { model ( { model
| state = | state =
ShowingList ShowingList
{ list | selected = Just message.id } { list | selected = Just message.id }
(ShowingMessage (VisibleMessage message Nothing)) (ShowingMessage message)
, bodyMode = bodyMode , bodyMode = bodyMode
, markSeenTimer = newTimer
} }
, Task.perform OpenedTime Time.now -- Set 1500ms delay before reporting message as seen to backend.
, Timer.schedule MarkSeenTriggered newTimer 1500
) )
updatePurge : Model -> ( Model, Cmd Msg ) {-| Updates model and triggers commands to purge this mailbox.
updatePurge model = -}
updateTriggerPurge : Model -> ( Model, Cmd Msg )
updateTriggerPurge model =
let let
cmd = cmd =
Cmd.batch Cmd.batch
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) [ Route.Mailbox model.mailboxName
, Api.purgeMailbox PurgedMailbox model.mailboxName |> model.session.router.toPath
|> Nav.replaceUrl model.session.key
, Api.purgeMailbox model.session PurgedMailbox model.mailboxName
] ]
in in
case model.state of case model.state of
ShowingList list _ -> ShowingList _ _ ->
( { model ( { model
| promptPurge = False | promptPurge = False
, session = Session.disableRouting model.session , session = Session.disableRouting model.session
@@ -428,8 +410,10 @@ updateDeleteMessage model message =
ShowingList (filter (\x -> x.id /= message.id) list) NoMessage ShowingList (filter (\x -> x.id /= message.id) list) NoMessage
} }
, Cmd.batch , Cmd.batch
[ Api.deleteMessage DeletedMessage message.mailbox message.id [ Api.deleteMessage model.session DeletedMessage message.mailbox message.id
, Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) , Route.Mailbox model.mailboxName
|> model.session.router.toPath
|> Nav.replaceUrl model.session.key
] ]
) )
@@ -437,32 +421,28 @@ updateDeleteMessage model message =
( model, Cmd.none ) ( model, Cmd.none )
updateMarkMessageSeen : Model -> Message -> ( Model, Cmd Msg ) {-| Updates both the active message, and the message list to mark the currently viewed message as seen.
updateMarkMessageSeen model message = -}
updateMarkMessageSeen : Model -> ( Model, Cmd Msg )
updateMarkMessageSeen model =
case model.state of case model.state of
ShowingList list (ShowingMessage visible) -> ShowingList messages (ShowingMessage visibleMessage) ->
let let
updateSeen header = updateHeader header =
if header.id == message.id then if header.id == visibleMessage.id then
{ header | seen = True } { header | seen = True }
else else
header header
map f messageList = newMessages =
{ messageList | headers = List.map f messageList.headers } { messages | headers = List.map updateHeader messages.headers }
in in
( { model ( { model
| state = | state =
ShowingList (map updateSeen list) ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
(ShowingMessage
{ visible
| message = { message | seen = True }
, markSeenAt = Nothing
}
)
} }
, Api.markMessageSeen MarkedSeen message.mailbox message.id , Api.markMessageSeen model.session MarkSeenLoaded visibleMessage.mailbox visibleMessage.id
) )
_ -> _ ->
@@ -476,7 +456,7 @@ updateOpenMessage model id =
{ model | session = Session.addRecent model.mailboxName model.session } { model | session = Session.addRecent model.mailboxName model.session }
in in
( updateSelected newModel id ( updateSelected newModel id
, Api.getMessage MessageLoaded model.mailboxName id , Api.getMessage model.session MessageLoaded model.mailboxName id
) )
@@ -529,11 +509,11 @@ view model =
++ " or enter a different username into the box on upper right." ++ " or enter a different username into the box on upper right."
) )
ShowingList _ (ShowingMessage { message }) -> ShowingList _ (ShowingMessage message) ->
viewMessage model.session.zone message model.bodyMode viewMessage model.session model.session.zone message model.bodyMode
ShowingList _ (Transitioning { message }) -> ShowingList _ (Transitioning message) ->
viewMessage model.session.zone message model.bodyMode viewMessage model.session model.session.zone message model.bodyMode
_ -> _ ->
text "" text ""
@@ -591,14 +571,14 @@ messageChip model selected message =
] ]
viewMessage : Time.Zone -> Message -> Body -> Html Msg viewMessage : Session -> Time.Zone -> Message -> Body -> Html Msg
viewMessage zone message bodyMode = viewMessage session zone message bodyMode =
let let
htmlUrl = htmlUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ] Api.serveUrl session [ "mailbox", message.mailbox, message.id, "html" ]
sourceUrl = sourceUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ] Api.serveUrl session [ "mailbox", message.mailbox, message.id, "source" ]
htmlButton = htmlButton =
if message.html == "" then if message.html == "" then
@@ -629,7 +609,7 @@ viewMessage zone message bodyMode =
] ]
, messageErrors message , messageErrors message
, messageBody message bodyMode , messageBody message bodyMode
, attachments message , attachments session message
] ]
@@ -692,20 +672,20 @@ messageBody message bodyMode =
] ]
attachments : Message -> Html Msg attachments : Session -> Message -> Html Msg
attachments message = attachments session message =
if List.isEmpty message.attachments then if List.isEmpty message.attachments then
div [] [] div [] []
else else
table [ class "attachments well" ] (List.map (attachmentRow message) message.attachments) table [ class "attachments well" ] (List.map (attachmentRow session message) message.attachments)
attachmentRow : Message -> Message.Attachment -> Html Msg attachmentRow : Session -> Message -> Message.Attachment -> Html Msg
attachmentRow message attach = attachmentRow session message attach =
let let
url = url =
Api.serveUrl Api.serveUrl session
[ "mailbox" [ "mailbox"
, message.mailbox , message.mailbox
, message.id , message.id

View File

@@ -1,10 +1,29 @@
module Page.Monitor exposing (Model, Msg, init, update, view) module Page.Monitor exposing (Model, Msg, init, update, view)
import Api
import Browser.Navigation as Nav
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import DateFormat as DF import DateFormat as DF
import Html exposing (..) import Html
import Html.Attributes exposing (..) exposing
( Attribute
, Html
, button
, div
, em
, h1
, node
, span
, table
, tbody
, td
, text
, th
, thead
, tr
)
import Html.Attributes exposing (class, src, tabindex)
import Html.Events as Events import Html.Events as Events
import Json.Decode as D import Json.Decode as D
import Route import Route
@@ -84,7 +103,9 @@ update msg model =
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg ) openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
openMessage header model = openMessage header model =
( model ( model
, Route.pushUrl model.session.key (Route.Message header.mailbox header.id) , Route.Message header.mailbox header.id
|> model.session.router.toPath
|> Nav.replaceUrl model.session.key
) )
@@ -115,8 +136,12 @@ view model =
[ button [ Events.onClick Clear ] [ text "Clear" ] [ button [ Events.onClick Clear ] [ text "Clear" ]
] ]
] ]
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
-- specified by `src`.
, node "monitor-messages" , node "monitor-messages"
[ Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool) [ src (Api.monitorUri model.session)
, Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
, Events.on "message" (D.map MessageReceived D.value) , Events.on "message" (D.map MessageReceived D.value)
] ]
[] []

View File

@@ -1,14 +1,21 @@
module Page.Status exposing (Model, Msg, init, subscriptions, update, view) module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
import Api import Api
import Data.Metrics as Metrics exposing (Metrics) import Data.Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig) import Data.ServerConfig exposing (ServerConfig)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import DateFormat.Relative as Relative import DateFormat.Relative as Relative
import Filesize import Filesize
import Html exposing (..) import Html
import Html.Attributes exposing (..) exposing
import Http exposing (Error) ( Html
, div
, h1
, h2
, i
, text
)
import Html.Attributes exposing (class)
import HttpUtil import HttpUtil
import Sparkline as Spark import Sparkline as Spark
import Svg.Attributes as SvgAttrib import Svg.Attributes as SvgAttrib
@@ -77,7 +84,7 @@ init session =
} }
, Cmd.batch , Cmd.batch
[ Task.perform Tick Time.now [ Task.perform Tick Time.now
, Api.getServerConfig ServerConfigLoaded , Api.getServerConfig session ServerConfigLoaded
] ]
) )
@@ -93,7 +100,7 @@ initDataSet =
subscriptions : Model -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions model = subscriptions _ =
Time.every (10 * 1000) Tick Time.every (10 * 1000) Tick
@@ -127,7 +134,7 @@ update msg model =
) )
Tick time -> Tick time ->
( { model | now = time }, Api.getServerMetrics MetricsReceived ) ( { model | now = time }, Api.getServerMetrics model.session MetricsReceived )
{-| Update all metrics in Model; increment xCounter. {-| Update all metrics in Model; increment xCounter.
@@ -271,18 +278,19 @@ configPanel maybeConfig =
, textEntry "SMTP Listener" config.smtpConfig.addr , textEntry "SMTP Listener" config.smtpConfig.addr
, textEntry "POP3 Listener" config.pop3Listener , textEntry "POP3 Listener" config.pop3Listener
, textEntry "HTTP Listener" config.webListener , textEntry "HTTP Listener" config.webListener
, textEntry "Accept Policy" (acceptPolicy config.smtpConfig) , textEntry "Accept Policy" (acceptPolicy config)
, textEntry "Store Policy" (storePolicy config.smtpConfig) , textEntry "Store Policy" (storePolicy config)
, textEntry "Store Type" config.storageConfig.storeType , textEntry "Store Type" config.storageConfig.storeType
, textEntry "Message Cap" (mailboxCap config) , textEntry "Message Cap" (mailboxCap config)
, textEntry "Retention Period" (retentionPeriod config) , textEntry "Retention Period" (retentionPeriod config)
] ]
acceptPolicy : ServerConfig -> String
acceptPolicy config = acceptPolicy config =
if config.defaultAccept then if config.smtpConfig.defaultAccept then
"All domains" "All domains"
++ (case config.rejectDomains of ++ (case config.smtpConfig.rejectDomains of
Nothing -> Nothing ->
"" ""
@@ -295,7 +303,7 @@ acceptPolicy config =
else else
"No domains" "No domains"
++ (case config.acceptDomains of ++ (case config.smtpConfig.acceptDomains of
Nothing -> Nothing ->
"" ""
@@ -307,10 +315,11 @@ acceptPolicy config =
) )
storePolicy : ServerConfig -> String
storePolicy config = storePolicy config =
if config.defaultStore then if config.smtpConfig.defaultStore then
"All domains" "All domains"
++ (case config.discardDomains of ++ (case config.smtpConfig.discardDomains of
Nothing -> Nothing ->
"" ""
@@ -323,7 +332,7 @@ storePolicy config =
else else
"No domains" "No domains"
++ (case config.storeDomains of ++ (case config.smtpConfig.storeDomains of
Nothing -> Nothing ->
"" ""
@@ -412,23 +421,6 @@ viewMetric metric =
] ]
viewLiveMetric : String -> (Int -> String) -> Int -> Html a -> Html a
viewLiveMetric label formatter value graph =
div [ class "metric" ]
[ div [ class "label" ] [ text label ]
, div [ class "value" ] [ text (formatter value) ]
, div [ class "graph" ]
[ graph
, text "(10min)"
]
]
graphNull : Html a
graphNull =
div [] []
graphSize : Spark.Size graphSize : Spark.Size
graphSize = graphSize =
{ width = 180 { width = 180

View File

@@ -1,8 +1,5 @@
module Route exposing (Route(..), fromUrl, href, pushUrl, replaceUrl) module Route exposing (Route(..), Router, newRouter)
import Browser.Navigation as Navigation exposing (Key)
import Html exposing (Attribute)
import Html.Attributes as Attr
import Url exposing (Url) import Url exposing (Url)
import Url.Builder as Builder import Url.Builder as Builder
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top) import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
@@ -17,6 +14,25 @@ type Route
| Status | Status
type alias Router =
{ fromUrl : Url -> Route
, toPath : Route -> String
}
{-| Returns a configured Router.
-}
newRouter : String -> Router
newRouter basePath =
let
newPath =
prepareBasePath basePath
in
{ fromUrl = fromUrl newPath
, toPath = toPath newPath
}
{-| Routes our application handles. {-| Routes our application handles.
-} -}
routes : List (Parser (Route -> a) a) routes : List (Parser (Route -> a) a)
@@ -29,10 +45,26 @@ routes =
] ]
{-| Returns the Route for a given URL.
-}
fromUrl : String -> Url -> Route
fromUrl basePath url =
let
relative =
{ url | path = String.replace basePath "" url.path }
in
case Parser.parse (oneOf routes) relative of
Nothing ->
Unknown url.path
Just route ->
route
{-| Convert route to a URI. {-| Convert route to a URI.
-} -}
routeToPath : Route -> String toPath : String -> Route -> String
routeToPath page = toPath basePath page =
let let
pieces = pieces =
case page of case page of
@@ -54,35 +86,32 @@ routeToPath page =
Status -> Status ->
[ "status" ] [ "status" ]
in in
Builder.absolute pieces [] basePath ++ Builder.absolute pieces []
{-| Make sure basePath starts with a slash and does not have trailing slashes.
-- PUBLIC HELPERS "inbucket/" becomes "/inbucket", "" remains ""
href : Route -> Attribute msg
href route =
Attr.href (routeToPath route)
replaceUrl : Key -> Route -> Cmd msg
replaceUrl key =
routeToPath >> Navigation.replaceUrl key
pushUrl : Key -> Route -> Cmd msg
pushUrl key =
routeToPath >> Navigation.pushUrl key
{-| Returns the Route for a given URL.
-} -}
fromUrl : Url -> Route prepareBasePath : String -> String
fromUrl location = prepareBasePath path =
case Parser.parse (oneOf routes) location of let
Nothing -> stripSlashes str =
Unknown location.path if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
Just route -> else if String.endsWith "/" str then
route stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
if newPath == "" then
""
else
"/" ++ newPath

58
ui/src/Timer.elm Normal file
View File

@@ -0,0 +1,58 @@
module Timer exposing (Timer, cancel, empty, replace, schedule)
import Process
import Task
{-| Implements an identity to track an asynchronous timer.
-}
type Timer
= Empty
| Idle Int
| Timer Int
empty : Timer
empty =
Empty
schedule : (Timer -> msg) -> Timer -> Float -> Cmd msg
schedule message timer millis =
Task.perform (always (message timer)) (Process.sleep millis)
{-| Replaces the provided timer with a newly created one.
-}
replace : Timer -> Timer
replace previous =
case previous of
Empty ->
Timer 0
Idle index ->
Timer (next index)
Timer index ->
Timer (next index)
{-| Cancels the provided timer without creating a replacement.
-}
cancel : Timer -> Timer
cancel previous =
case previous of
Timer index ->
Idle index
_ ->
previous
next : Int -> Int
next index =
if index > 2 ^ 30 then
0
else
index + 1

View File

@@ -69,7 +69,8 @@
grid-gap: 1px 20px; grid-gap: 1px 20px;
grid: grid:
"ctrl mesg" auto "ctrl mesg" auto
"list mesg" 1fr / minmax(200px, 300px) minmax(650px, 1000px); "list mesg" 1fr
/ minmax(200px, 300px) minmax(650px, auto);
height: 100%; height: 100%;
} }

View File

@@ -116,23 +116,29 @@ a.button {
justify-content: center; justify-content: center;
grid-gap: 20px; grid-gap: 20px;
grid-template: grid-template:
"lpad head rpad" auto "head head head" auto
"lpad page rpad" 1fr "lpad page rpad" 1fr
"foot foot foot" auto / minmax(20px, auto) 1fr minmax(20px, auto); "foot foot foot" auto / 1px 1fr 1px;
height: 100vh; height: auto;
} }
@media (max-width: 999px) { .desktop {
display: none;
}
@media screen and (min-width: 1000px) {
.app { .app {
grid-column-gap: 40px;
grid-template: grid-template:
"head head head" auto "lpad head rpad" auto
"lpad page rpad" 1fr "lpad page rpad" 1fr
"foot foot foot" auto / 1px 1fr 1px; "foot foot foot" auto
height: auto; / 1fr minmax(auto, 1300px) 1fr;
height: 100vh;
} }
.desktop { td.desktop, th.desktop {
display: none; display: table-cell;
} }
} }
@@ -217,6 +223,10 @@ h3 {
padding: 10px !important; padding: 10px !important;
} }
.modal:focus {
outline: none;
}
/** BUTTONS */ /** BUTTONS */
.button-bar { .button-bar {

View File

@@ -3,22 +3,55 @@
customElements.define( customElements.define(
'monitor-messages', 'monitor-messages',
class MonitorMessages extends HTMLElement { class MonitorMessages extends HTMLElement {
static get observedAttributes() {
return [ 'src' ]
}
constructor() { constructor() {
const self = super() super()
// TODO make URI/URL configurable. this._url = null // Current websocket URL.
var uri = '/api/v1/monitor/messages' this._socket = null // Currently open WebSocket.
self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri
self._socket = null
} }
connectedCallback() { connectedCallback() {
if (this.hasAttribute('src')) {
this.wsOpen(this.getAttribute('src'))
}
}
attributeChangedCallback() {
// Checking _socket prevents connection attempts prior to connectedCallback().
if (this._socket && this.hasAttribute('src')) {
this.wsOpen(this.getAttribute('src'))
}
}
disconnectedCallback() {
this.wsClose()
}
// Connects to WebSocket and registers event listeners.
wsOpen(uri) {
const url =
((window.location.protocol === 'https:') ? 'wss://' : 'ws://') +
window.location.host + uri
if (this._socket && url === this._url) {
// Already connected to same URL.
return
}
this.wsClose()
this._url = url
console.info("Connecting to WebSocket", url)
const ws = new WebSocket(url)
this._socket = ws
// Register event listeners.
const self = this const self = this
self._socket = new WebSocket(self._url) ws.addEventListener('open', function (_e) {
var ws = self._socket
ws.addEventListener('open', function (e) {
self.dispatchEvent(new CustomEvent('connected', { detail: true })) self.dispatchEvent(new CustomEvent('connected', { detail: true }))
}) })
ws.addEventListener('close', function (e) { ws.addEventListener('close', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: false })) self.dispatchEvent(new CustomEvent('connected', { detail: false }))
}) })
ws.addEventListener('message', function (e) { ws.addEventListener('message', function (e) {
@@ -28,11 +61,20 @@ customElements.define(
}) })
} }
disconnectedCallback() { // Closes WebSocket connection.
var ws = this._socket wsClose() {
const ws = this._socket
if (ws) { if (ws) {
ws.close() ws.close()
} }
} }
get src() {
return this.getAttribute('src')
}
set src(value) {
this.setAttribute('src', value)
}
} }
) )

View File

@@ -50,6 +50,12 @@
padding: 12px 15px; padding: 12px 15px;
} }
.navbar-dropdown {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-dropdown-button { .navbar-dropdown-button {
display: none; display: none;
} }
@@ -71,6 +77,9 @@ li.navbar-active span,
.navbar-dropdown-content a { .navbar-dropdown-content a {
color: var(--navbar-color) !important; color: var(--navbar-color) !important;
display: block; display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.navbar-dropdown-content a:hover { .navbar-dropdown-content a:hover {
@@ -139,6 +148,7 @@ li.navbar-active span,
.navbar-dropdown { .navbar-dropdown {
padding: 15px 19px 15px 25px; padding: 15px 19px 15px 25px;
max-width: 350px;
} }
.navbar-dropdown-button { .navbar-dropdown-button {

View File

@@ -6,7 +6,7 @@ module.exports = (env, argv) => {
const config = { const config = {
output: { output: {
filename: 'static/[name].[hash:8].js', filename: 'static/[name].[hash:8].js',
publicPath: '/', publicPath: '',
}, },
module: { module: {
rules: [ rules: [
@@ -54,11 +54,18 @@ module.exports = (env, argv) => {
template: 'public/index.html', template: 'public/index.html',
favicon: 'public/favicon.png', favicon: 'public/favicon.png',
}), }),
new HtmlWebpackPlugin({
filename: 'index-dev.html',
template: 'public/index-dev.html',
favicon: 'public/favicon.png',
}),
], ],
devServer: { devServer: {
historyApiFallback: {
index: '/index-dev.html',
},
index: 'index-dev.html',
inline: true, inline: true,
historyApiFallback: true,
stats: { colors: true },
overlay: true, overlay: true,
open: true, open: true,
proxy: [{ proxy: [{
@@ -66,6 +73,7 @@ module.exports = (env, argv) => {
target: 'http://localhost:9000', target: 'http://localhost:9000',
ws: true, ws: true,
}], }],
stats: { colors: true },
watchOptions: { watchOptions: {
ignored: /node_modules/, ignored: /node_modules/,
}, },