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:
@@ -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
15
.github/workflows/docker-build.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
15
.travis.yml
15
.travis.yml
@@ -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"
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
30
go.mod
@@ -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
75
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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?"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
ui/elm.json
18
ui/elm.json
@@ -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
5133
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
21
ui/public/index-dev.html
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
130
ui/src/Api.elm
130
ui/src/Api.elm
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" ] [] ]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
58
ui/src/Modal.elm
Normal 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"
|
||||||
@@ -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 )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
58
ui/src/Timer.elm
Normal 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
|
||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user