1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 09:37:02 +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
swaks-tests
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
# vim swp files
# vim files
*.swp
*.swo
tags
tags.*
# Desktop Services Store on macOS
.DS_Store
@@ -41,6 +43,7 @@ _testmain.go
# Elm UI
# elm-package generated files
/ui/index.html
/ui/elm-stuff
/ui/tests/elm-stuff
# elm-repl generated files

View File

@@ -1,4 +1,4 @@
sudo: false
dist: bionic
env:
global:
@@ -12,10 +12,13 @@ install:
jobs:
include:
- go: "1.11.x"
- go: "master"
- go: "1.14.x"
- go: "1.15.x"
- 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:
- "cd ui"
- "npm ci"
@@ -23,9 +26,9 @@ jobs:
- "elm-format --validate ."
- "npm run build"
- stage: deploy
go: "1.11.x"
go: "1.15.x"
before_install:
- "nvm install 10.13.0"
- "nvm install 10.19.0"
install:
- "cd ui"
- "npm ci"

View File

@@ -4,6 +4,22 @@ Change Log
All notable changes to this project will be documented in this file.
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]
### Added
@@ -212,6 +228,7 @@ No change from beta1.
specific message.
[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-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

View File

@@ -1,22 +1,31 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Build
FROM golang:1.12-alpine3.10 as builder
RUN apk add --no-cache --virtual .build-deps git make npm
# Install build-time dependencies
FROM golang:1.15-alpine3.12 as builder
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
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 \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
# Build frontend
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm i
RUN npm run build
# Run in minimal image
FROM alpine:3.10
FROM alpine:3.12
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin
@@ -36,6 +45,9 @@ ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
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
EXPOSE 2500 9000 1100

View File

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

View File

@@ -28,6 +28,7 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
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
`INBUCKET_SMTP_TLSPRIVKEY`
`INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation.
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`
### 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
`INBUCKET_WEB_UIDIR`

View File

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

View File

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

30
go.mod
View File

@@ -2,20 +2,24 @@ module github.com/inbucket/inbucket
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-test/deep v1.0.2 // indirect
github.com/google/subcommands v1.0.1
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.7.3
github.com/gorilla/websocket v1.4.0
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 // indirect
github.com/jhillyerd/enmime v0.6.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/jhillyerd/enmime v0.8.1
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.4 // indirect
github.com/microcosm-cc/bluemonday v1.0.2
github.com/olekukonko/tablewriter v0.0.1 // indirect
github.com/rs/zerolog v1.15.0
github.com/stretchr/testify v1.3.0
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80
golang.org/x/text v0.3.2 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/microcosm-cc/bluemonday v1.0.4
github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.19.0
github.com/stretchr/testify v1.6.1
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.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/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/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
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/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
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/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
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/jhillyerd/enmime v0.6.0/go.mod h1:lwWyVhHVBdmzXx3wtRTmpIdNEJyZ85LJuVqZHVK/Rlo=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
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/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/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/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rs/zerolog v1.19.0 h1:hYz4ZVdUgjXTBUmrkrw55j1nHx68LfOKIQk5IYtyScg=
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/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/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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/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/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/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.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-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.
type Web struct {
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"`
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?"`

View File

@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
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,
// (?:) is non-grouping sub-match
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
func (s State) String() string {
switch s {
@@ -314,11 +314,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
return
}
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.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
@@ -433,6 +437,7 @@ func (s *Session) dataHandler() {
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, 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)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
}

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
func TestReadyState(t *testing.T) {
ds := test.NewStore()

View File

@@ -1,5 +1,6 @@
package web
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
import (
"html/template"
"net/http"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -82,3 +84,21 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
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"
"encoding/json"
"expvar"
"html/template"
"net"
"net/http"
"net/http/pprof"
"net/url"
"os"
"path/filepath"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -56,33 +59,59 @@ func Initialize(
msgHub = mh
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.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.Handle("/debug/vars", expvar.Handler())
Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
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.
Router.PathPrefix("/static").Handler(
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path("/favicon.png").Handler(
Router.PathPrefix(prefix("/static")).Handler(
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path(prefix("/favicon.png")).Handler(
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.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
Router.Path("/").Handler(spaHandler)
Router.Path("/monitor").Handler(spaHandler)
Router.Path("/status").Handler(spaHandler)
Router.PathPrefix("/m/").Handler(spaHandler)
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
// Error handlers.
Router.NotFoundHandler = noMatchHandler(
@@ -131,6 +160,7 @@ func Start(ctx context.Context) {
func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{
BasePath: webConfig.BasePath,
MonitorVisible: webConfig.MonitorVisible,
}
b, err := json.Marshal(o)

View File

@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
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
import (
"fmt"
"net/mail"
"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 = [
dpkg
elmPackages.elm
elmPackages.elm-analyse
elmPackages.elm-format
elmPackages.elm-language-server
elmPackages.elm-test
go
golint
nodejs-10_x

View File

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

5133
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,6 +6,7 @@ module Api exposing
, getServerConfig
, getServerMetrics
, markMessageSeen
, monitorUri
, purgeMailbox
, serveUrl
)
@@ -14,10 +15,12 @@ import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import Http
import HttpUtil
import Json.Decode as Decode
import Json.Encode as Encode
import String
import Url.Builder
@@ -29,31 +32,17 @@ type alias HttpResult msg =
Result HttpUtil.Error () -> msg
{-| Builds a public REST API URL (see wiki).
-}
apiV1Url : List String -> String
apiV1Url elements =
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
deleteMessage session msg mailboxName id =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
{-| Builds an internal `serve` REST API URL; only used by this UI.
-}
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 =
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
getHeaderList session msg mailboxName =
let
context =
{ method = "GET"
, url = apiV1Url [ "mailbox", mailboxName ]
, url = apiV1Url session [ "mailbox", mailboxName ]
}
in
Http.get
@@ -62,12 +51,12 @@ getHeaderList msg mailboxName =
}
getGreeting : DataResult msg String -> Cmd msg
getGreeting msg =
getGreeting : Session -> DataResult msg String -> Cmd msg
getGreeting session msg =
let
context =
{ method = "GET"
, url = serveUrl [ "greeting" ]
, url = serveUrl session [ "greeting" ]
}
in
Http.get
@@ -76,12 +65,12 @@ getGreeting msg =
}
getMessage : DataResult msg Message -> String -> String -> Cmd msg
getMessage msg mailboxName id =
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
getMessage session msg mailboxName id =
let
context =
{ method = "GET"
, url = serveUrl [ "mailbox", mailboxName, id ]
, url = serveUrl session [ "mailbox", mailboxName, id ]
}
in
Http.get
@@ -90,12 +79,12 @@ getMessage msg mailboxName id =
}
getServerConfig : DataResult msg ServerConfig -> Cmd msg
getServerConfig msg =
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
getServerConfig session msg =
let
context =
{ method = "GET"
, url = serveUrl [ "status" ]
, url = serveUrl session [ "status" ]
}
in
Http.get
@@ -104,12 +93,19 @@ getServerConfig msg =
}
getServerMetrics : DataResult msg Metrics -> Cmd msg
getServerMetrics msg =
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
getServerMetrics session msg =
let
context =
{ method = "GET"
, url = Url.Builder.absolute [ "debug", "vars" ] []
, url =
Url.Builder.absolute
(splitBasePath session.config.basePath
++ [ "debug"
, "vars"
]
)
[]
}
in
Http.get
@@ -118,15 +114,73 @@ getServerMetrics msg =
}
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg
markMessageSeen msg mailboxName id =
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
markMessageSeen session msg mailboxName id =
-- The URL tells the API which message ID to update, so we only need to indicate the
-- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody
|> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ])
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
purgeMailbox : HttpResult msg -> String -> Cmd msg
purgeMailbox msg mailboxName =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ])
monitorUri : Session -> String
monitorUri session =
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 =
{ monitorVisible : Bool
{ basePath : String
, monitorVisible : Bool
}
decoder : D.Decoder AppConfig
decoder =
D.succeed AppConfig
|> P.optional "base-path" D.string ""
|> P.required "monitor-visible" D.bool
default : AppConfig
default =
AppConfig True
AppConfig "" True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,46 @@
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
import Browser.Navigation as Nav
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
exposing
( attribute
, class
, classList
, href
, id
, placeholder
, rel
, selected
, target
, type_
, value
)
import Html.Events as Events
import Modal
import Route exposing (Route)
import Timer exposing (Timer)
{-| Used to highlight current page in navbar.
@@ -31,8 +54,9 @@ type Page
type alias Model msg =
{ mapMsg : Msg -> msg
, menuVisible : Bool
, recentVisible : Bool
, mainMenuVisible : Bool
, recentMenuVisible : Bool
, recentMenuTimer : Timer
, mailboxName : String
}
@@ -40,8 +64,9 @@ type alias Model msg =
init : (Msg -> msg) -> Model msg
init mapMsg =
{ mapMsg = mapMsg
, menuVisible = False
, recentVisible = False
, mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.empty
, mailboxName = ""
}
@@ -51,18 +76,24 @@ init mapMsg =
reset : Model msg -> Model msg
reset model =
{ model
| menuVisible = False
, recentVisible = False
| mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.cancel model.recentMenuTimer
, mailboxName = ""
}
type Msg
= ClearFlash
| MainMenuToggled
| ModalFocused Modal.Msg
| ModalUnfocused
| OnMailboxNameInput String
| OpenMailbox
| ShowRecent Bool
| ToggleMenu
| RecentMenuMouseOver
| RecentMenuMouseOut
| RecentMenuTimeout Timer
| RecentMenuToggled
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
@@ -74,6 +105,21 @@ update msg model session =
, 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 ->
( { model | mailboxName = name }
, session
@@ -87,17 +133,48 @@ update msg model session =
else
( model
, session
, Route.pushUrl session.key (Route.Mailbox model.mailboxName)
, Route.Mailbox model.mailboxName
|> session.router.toPath
|> Nav.pushUrl session.key
)
ShowRecent visible ->
( { model | recentVisible = visible }
RecentMenuMouseOver ->
( { model
| recentMenuVisible = True
, recentMenuTimer = Timer.cancel model.recentMenuTimer
}
, session
, Cmd.none
)
ToggleMenu ->
( { model | menuVisible = not model.menuVisible }
RecentMenuMouseOut ->
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
, Cmd.none
)
@@ -118,17 +195,17 @@ frame { model, session, activePage, activeMailbox, modal, content } =
div [ class "app" ]
[ header []
[ 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" ] [] ]
, span [ class "navbar-brand" ]
[ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ]
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
[ if session.config.monitorVisible then
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
else
text ""
, navbarLink Status Route.Status [ text "Status" ] activePage
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
, navbarRecent activePage activeMailbox model session
, li [ class "navbar-mailbox" ]
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
@@ -145,8 +222,8 @@ frame { model, session, activePage, activeMailbox, modal, content } =
]
]
, div [ class "navbar-bg" ] [ text "" ]
, frameModal modal
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content)
, Modal.view (ModalUnfocused |> model.mapMsg) modal
, div [ class "page" ] (errorFlash model session.flash :: content)
, footer []
[ div [ class "footer" ]
[ 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 maybeFlash =
let
@@ -198,10 +263,10 @@ externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a
navbarLink page route linkContent activePage =
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page url linkContent 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.
@@ -229,29 +294,22 @@ navbarRecent page activeMailbox model session =
else
session.persistent.recentMailboxes
dropdownExpanded =
if model.recentVisible then
"true"
else
"false"
recentLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
in
li
[ class "navbar-dropdown-container"
, classList [ ( "navbar-active", active ) ]
, attribute "aria-haspopup" "true"
, ariaExpanded model.recentVisible
, Events.onMouseOver (ShowRecent True |> model.mapMsg)
, Events.onMouseOut (ShowRecent False |> model.mapMsg)
, ariaExpanded model.recentMenuVisible
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
]
[ span [ class "navbar-dropdown" ]
[ text title
, button
[ class "navbar-dropdown-button"
, Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg)
, Events.onClick (RecentMenuToggled |> model.mapMsg)
]
[ i [ class "fas fa-chevron-down" ] [] ]
]

View File

@@ -4,7 +4,7 @@ import Browser exposing (Document, UrlRequest)
import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig)
import Data.Session as Session exposing (Session)
import Html exposing (..)
import Html exposing (Html)
import Json.Decode as D exposing (Value)
import Layout
import Page.Home as Home
@@ -66,7 +66,7 @@ init configValue location key =
}
route =
Route.fromUrl location
session.router.fromUrl location
( model, cmd ) =
changeRouteTo route initModel
@@ -167,7 +167,7 @@ updateMain msg model session =
UrlChanged url ->
-- Responds to new browser URL.
if session.routing then
changeRouteTo (Route.fromUrl url) model
changeRouteTo (session.router.fromUrl url) model
else
-- 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 Data.Session as Session exposing (Session)
import Html exposing (..)
import Html.Attributes exposing (..)
import Http
import Html exposing (Html)
import Html.Attributes exposing (class, property)
import HttpUtil
import Json.Encode as Encode
import Ports
@@ -22,7 +20,7 @@ type alias Model =
init : Session -> ( Model, Cmd Msg )
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)
import Api
import Browser.Navigation as Nav
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 DateFormat as DF
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
exposing
( alt
@@ -15,7 +41,6 @@ import Html.Attributes
, disabled
, download
, href
, id
, placeholder
, property
, tabindex
@@ -24,14 +49,14 @@ import Html.Attributes
, value
)
import Html.Events as Events
import Http exposing (Error)
import HttpUtil
import Json.Decode as D
import Json.Encode as E
import Ports
import Modal
import Route
import Task
import Time exposing (Posix)
import Timer exposing (Timer)
@@ -51,8 +76,8 @@ type State
type MessageState
= NoMessage
| LoadingMessage
| ShowingMessage VisibleMessage
| Transitioning VisibleMessage
| ShowingMessage Message
| Transitioning Message
type alias MessageID =
@@ -66,12 +91,6 @@ type alias MessageList =
}
type alias VisibleMessage =
{ message : Message
, markSeenAt : Maybe Int
}
type alias Model =
{ session : Session
, mailboxName : String
@@ -79,6 +98,7 @@ type alias Model =
, bodyMode : Body
, searchInput : String
, promptPurge : Bool
, markSeenTimer : Timer
, now : Posix
}
@@ -91,17 +111,18 @@ init session mailboxName selection =
, bodyMode = SafeHtmlBody
, searchInput = ""
, promptPurge = False
, markSeenTimer = Timer.empty
, now = Time.millisToPosix 0
}
, load mailboxName
, load session mailboxName
)
load : String -> Cmd Msg
load mailboxName =
load : Session -> String -> Cmd Msg
load session mailboxName =
Cmd.batch
[ 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 =
let
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
]
subscriptions _ =
Time.every (30 * 1000) Tick
@@ -138,13 +143,11 @@ type Msg
= ListLoaded (Result HttpUtil.Error (List MessageHeader))
| ClickMessage MessageID
| ListKeyPress String Int
| OpenMessage MessageID
| CloseMessage
| MessageLoaded (Result HttpUtil.Error Message)
| MessageBody Body
| OpenedTime Posix
| MarkSeenTick Posix
| MarkedSeen (Result HttpUtil.Error ())
| MarkSeenTriggered Timer
| MarkSeenLoaded (Result HttpUtil.Error ())
| DeleteMessage Message
| DeletedMessage (Result HttpUtil.Error ())
| PurgeMailboxPrompt
@@ -153,6 +156,7 @@ type Msg
| PurgedMailbox (Result HttpUtil.Error ())
| OnSearchInput String
| Tick Posix
| ModalFocused Modal.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
@@ -162,14 +166,13 @@ update msg model =
( updateSelected { model | session = Session.disableRouting model.session } id
, Cmd.batch
[ -- Update browser location.
Route.replaceUrl model.session.key (Route.Message model.mailboxName id)
, Api.getMessage MessageLoaded model.mailboxName id
Route.Message 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 ->
case model.state of
ShowingList list _ ->
@@ -225,10 +228,10 @@ update msg model =
, Cmd.none
)
MarkedSeen (Ok _) ->
MarkSeenLoaded (Ok _) ->
( model, Cmd.none )
MarkedSeen (Err err) ->
MarkSeenLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
@@ -244,44 +247,22 @@ update msg model =
MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none )
ModalFocused message ->
( { model | session = Modal.updateSession message model.session }
, Cmd.none
)
OnSearchInput 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 ->
( { model | promptPurge = True }, Cmd.none )
( { model | promptPurge = True }, Modal.resetFocusCmd ModalFocused )
PurgeMailboxCanceled ->
( { model | promptPurge = False }, Cmd.none )
PurgeMailboxConfirmed ->
updatePurge model
updateTriggerPurge model
PurgedMailbox (Ok _) ->
( model, Cmd.none )
@@ -291,22 +272,13 @@ update msg model =
, Cmd.none
)
MarkSeenTick now ->
case model.state of
ShowingList _ (ShowingMessage { message, markSeenAt }) ->
case markSeenAt of
Just deadline ->
if Time.posixToMillis now >= deadline then
updateMarkMessageSeen model message
MarkSeenTriggered timer ->
if timer == model.markSeenTimer then
-- Matching timer means we have changed messages, mark this one seen.
updateMarkMessageSeen model
else
( model, Cmd.none )
Nothing ->
( model, Cmd.none )
_ ->
( model, Cmd.none )
else
( model, Cmd.none )
Tick now ->
( { model | now = now }, Cmd.none )
@@ -329,28 +301,38 @@ updateMessageResult model message =
( model, Cmd.none )
ShowingList list _ ->
let
newTimer =
Timer.replace model.markSeenTimer
in
( { model
| state =
ShowingList
{ list | selected = Just message.id }
(ShowingMessage (VisibleMessage message Nothing))
(ShowingMessage message)
, 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 )
updatePurge model =
{-| Updates model and triggers commands to purge this mailbox.
-}
updateTriggerPurge : Model -> ( Model, Cmd Msg )
updateTriggerPurge model =
let
cmd =
Cmd.batch
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
, Api.purgeMailbox PurgedMailbox model.mailboxName
[ Route.Mailbox model.mailboxName
|> model.session.router.toPath
|> Nav.replaceUrl model.session.key
, Api.purgeMailbox model.session PurgedMailbox model.mailboxName
]
in
case model.state of
ShowingList list _ ->
ShowingList _ _ ->
( { model
| promptPurge = False
, session = Session.disableRouting model.session
@@ -428,8 +410,10 @@ updateDeleteMessage model message =
ShowingList (filter (\x -> x.id /= message.id) list) NoMessage
}
, Cmd.batch
[ Api.deleteMessage DeletedMessage message.mailbox message.id
, Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
[ Api.deleteMessage model.session DeletedMessage message.mailbox message.id
, Route.Mailbox model.mailboxName
|> model.session.router.toPath
|> Nav.replaceUrl model.session.key
]
)
@@ -437,32 +421,28 @@ updateDeleteMessage model message =
( model, Cmd.none )
updateMarkMessageSeen : Model -> Message -> ( Model, Cmd Msg )
updateMarkMessageSeen model message =
{-| Updates both the active message, and the message list to mark the currently viewed message as seen.
-}
updateMarkMessageSeen : Model -> ( Model, Cmd Msg )
updateMarkMessageSeen model =
case model.state of
ShowingList list (ShowingMessage visible) ->
ShowingList messages (ShowingMessage visibleMessage) ->
let
updateSeen header =
if header.id == message.id then
updateHeader header =
if header.id == visibleMessage.id then
{ header | seen = True }
else
header
map f messageList =
{ messageList | headers = List.map f messageList.headers }
newMessages =
{ messages | headers = List.map updateHeader messages.headers }
in
( { model
| state =
ShowingList (map updateSeen list)
(ShowingMessage
{ visible
| message = { message | seen = True }
, markSeenAt = Nothing
}
)
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
}
, 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 }
in
( 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."
)
ShowingList _ (ShowingMessage { message }) ->
viewMessage model.session.zone message model.bodyMode
ShowingList _ (ShowingMessage message) ->
viewMessage model.session model.session.zone message model.bodyMode
ShowingList _ (Transitioning { message }) ->
viewMessage model.session.zone message model.bodyMode
ShowingList _ (Transitioning message) ->
viewMessage model.session model.session.zone message model.bodyMode
_ ->
text ""
@@ -591,14 +571,14 @@ messageChip model selected message =
]
viewMessage : Time.Zone -> Message -> Body -> Html Msg
viewMessage zone message bodyMode =
viewMessage : Session -> Time.Zone -> Message -> Body -> Html Msg
viewMessage session zone message bodyMode =
let
htmlUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
Api.serveUrl session [ "mailbox", message.mailbox, message.id, "html" ]
sourceUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
Api.serveUrl session [ "mailbox", message.mailbox, message.id, "source" ]
htmlButton =
if message.html == "" then
@@ -629,7 +609,7 @@ viewMessage zone message bodyMode =
]
, messageErrors message
, messageBody message bodyMode
, attachments message
, attachments session message
]
@@ -692,20 +672,20 @@ messageBody message bodyMode =
]
attachments : Message -> Html Msg
attachments message =
attachments : Session -> Message -> Html Msg
attachments session message =
if List.isEmpty message.attachments then
div [] []
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 message attach =
attachmentRow : Session -> Message -> Message.Attachment -> Html Msg
attachmentRow session message attach =
let
url =
Api.serveUrl
Api.serveUrl session
[ "mailbox"
, message.mailbox
, message.id

View File

@@ -1,10 +1,29 @@
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.Session as Session exposing (Session)
import DateFormat as DF
import Html exposing (..)
import Html.Attributes exposing (..)
import Html
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 Json.Decode as D
import Route
@@ -84,7 +103,9 @@ update msg model =
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
openMessage header 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" ]
]
]
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
-- specified by `src`.
, 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)
]
[]

View File

@@ -1,14 +1,21 @@
module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
import Api
import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Metrics exposing (Metrics)
import Data.ServerConfig exposing (ServerConfig)
import Data.Session as Session exposing (Session)
import DateFormat.Relative as Relative
import Filesize
import Html exposing (..)
import Html.Attributes exposing (..)
import Http exposing (Error)
import Html
exposing
( Html
, div
, h1
, h2
, i
, text
)
import Html.Attributes exposing (class)
import HttpUtil
import Sparkline as Spark
import Svg.Attributes as SvgAttrib
@@ -77,7 +84,7 @@ init session =
}
, Cmd.batch
[ Task.perform Tick Time.now
, Api.getServerConfig ServerConfigLoaded
, Api.getServerConfig session ServerConfigLoaded
]
)
@@ -93,7 +100,7 @@ initDataSet =
subscriptions : Model -> Sub Msg
subscriptions model =
subscriptions _ =
Time.every (10 * 1000) Tick
@@ -127,7 +134,7 @@ update msg model =
)
Tick time ->
( { model | now = time }, Api.getServerMetrics MetricsReceived )
( { model | now = time }, Api.getServerMetrics model.session MetricsReceived )
{-| Update all metrics in Model; increment xCounter.
@@ -271,18 +278,19 @@ configPanel maybeConfig =
, textEntry "SMTP Listener" config.smtpConfig.addr
, textEntry "POP3 Listener" config.pop3Listener
, textEntry "HTTP Listener" config.webListener
, textEntry "Accept Policy" (acceptPolicy config.smtpConfig)
, textEntry "Store Policy" (storePolicy config.smtpConfig)
, textEntry "Accept Policy" (acceptPolicy config)
, textEntry "Store Policy" (storePolicy config)
, textEntry "Store Type" config.storageConfig.storeType
, textEntry "Message Cap" (mailboxCap config)
, textEntry "Retention Period" (retentionPeriod config)
]
acceptPolicy : ServerConfig -> String
acceptPolicy config =
if config.defaultAccept then
if config.smtpConfig.defaultAccept then
"All domains"
++ (case config.rejectDomains of
++ (case config.smtpConfig.rejectDomains of
Nothing ->
""
@@ -295,7 +303,7 @@ acceptPolicy config =
else
"No domains"
++ (case config.acceptDomains of
++ (case config.smtpConfig.acceptDomains of
Nothing ->
""
@@ -307,10 +315,11 @@ acceptPolicy config =
)
storePolicy : ServerConfig -> String
storePolicy config =
if config.defaultStore then
if config.smtpConfig.defaultStore then
"All domains"
++ (case config.discardDomains of
++ (case config.smtpConfig.discardDomains of
Nothing ->
""
@@ -323,7 +332,7 @@ storePolicy config =
else
"No domains"
++ (case config.storeDomains of
++ (case config.smtpConfig.storeDomains of
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 =
{ 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.Builder as Builder
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
@@ -17,6 +14,25 @@ type Route
| 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 : 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.
-}
routeToPath : Route -> String
routeToPath page =
toPath : String -> Route -> String
toPath basePath page =
let
pieces =
case page of
@@ -54,35 +86,32 @@ routeToPath page =
Status ->
[ "status" ]
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
fromUrl location =
case Parser.parse (oneOf routes) location of
Nothing ->
Unknown location.path
prepareBasePath : String -> String
prepareBasePath path =
let
stripSlashes str =
if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
Just route ->
route
else if String.endsWith "/" str then
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:
"ctrl mesg" auto
"list mesg" 1fr / minmax(200px, 300px) minmax(650px, 1000px);
"list mesg" 1fr
/ minmax(200px, 300px) minmax(650px, auto);
height: 100%;
}

View File

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

View File

@@ -3,22 +3,55 @@
customElements.define(
'monitor-messages',
class MonitorMessages extends HTMLElement {
static get observedAttributes() {
return [ 'src' ]
}
constructor() {
const self = super()
// TODO make URI/URL configurable.
var uri = '/api/v1/monitor/messages'
self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri
self._socket = null
super()
this._url = null // Current websocket URL.
this._socket = null // Currently open WebSocket.
}
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
self._socket = new WebSocket(self._url)
var ws = self._socket
ws.addEventListener('open', function (e) {
ws.addEventListener('open', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: true }))
})
ws.addEventListener('close', function (e) {
ws.addEventListener('close', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: false }))
})
ws.addEventListener('message', function (e) {
@@ -28,11 +61,20 @@ customElements.define(
})
}
disconnectedCallback() {
var ws = this._socket
// Closes WebSocket connection.
wsClose() {
const ws = this._socket
if (ws) {
ws.close()
}
}
get src() {
return this.getAttribute('src')
}
set src(value) {
this.setAttribute('src', value)
}
}
)

View File

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

View File

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