mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 09:37:02 +00:00
Merge branch 'release/3.0.0-beta3'
This commit is contained in:
@@ -6,3 +6,8 @@ inbucket
|
||||
inbucket.exe
|
||||
swaks-tests
|
||||
target
|
||||
tags
|
||||
tags.*
|
||||
ui/dist
|
||||
ui/elm-stuff
|
||||
ui/node_modules
|
||||
|
||||
15
.github/workflows/docker-build.yml
vendored
Normal file
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
|
||||
|
||||
# vim swp files
|
||||
# vim files
|
||||
*.swp
|
||||
*.swo
|
||||
tags
|
||||
tags.*
|
||||
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
@@ -41,6 +43,7 @@ _testmain.go
|
||||
|
||||
# Elm UI
|
||||
# elm-package generated files
|
||||
/ui/index.html
|
||||
/ui/elm-stuff
|
||||
/ui/tests/elm-stuff
|
||||
# elm-repl generated files
|
||||
|
||||
15
.travis.yml
15
.travis.yml
@@ -1,4 +1,4 @@
|
||||
sudo: false
|
||||
dist: bionic
|
||||
|
||||
env:
|
||||
global:
|
||||
@@ -12,10 +12,13 @@ install:
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- go: "1.11.x"
|
||||
- go: "master"
|
||||
- go: "1.14.x"
|
||||
- go: "1.15.x"
|
||||
- language: elm
|
||||
elm: "0.19.0"
|
||||
elm: "latest-0.19.1"
|
||||
elm_format: "latest-0.19.1"
|
||||
elm_test: "latest-0.19.1"
|
||||
node_js: "10.16.0"
|
||||
install:
|
||||
- "cd ui"
|
||||
- "npm ci"
|
||||
@@ -23,9 +26,9 @@ jobs:
|
||||
- "elm-format --validate ."
|
||||
- "npm run build"
|
||||
- stage: deploy
|
||||
go: "1.11.x"
|
||||
go: "1.15.x"
|
||||
before_install:
|
||||
- "nvm install 10.13.0"
|
||||
- "nvm install 10.19.0"
|
||||
install:
|
||||
- "cd ui"
|
||||
- "npm ci"
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,22 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [v3.0.0-beta3]
|
||||
|
||||
### Added
|
||||
- Docker `HEALTHCHECK`
|
||||
- Mouse-out delay to improve pop-up menu navigation
|
||||
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||
|
||||
### Changed
|
||||
- Updated frontend and backend dependencies, Docker image base
|
||||
|
||||
### Fixed
|
||||
- Improved layout on mobile and wide displays
|
||||
- Prevent unexpected input for modal dialogs
|
||||
- Allow empty SMTP `MAIL FROM:<>`
|
||||
|
||||
|
||||
## [v3.0.0-beta2]
|
||||
|
||||
### Added
|
||||
@@ -212,6 +228,7 @@ No change from beta1.
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,22 +1,31 @@
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
# Build
|
||||
FROM golang:1.12-alpine3.10 as builder
|
||||
RUN apk add --no-cache --virtual .build-deps git make npm
|
||||
# Install build-time dependencies
|
||||
FROM golang:1.15-alpine3.12 as builder
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
RUN make clean deps
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf dist elm-stuff node_modules
|
||||
RUN npm ci
|
||||
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
|
||||
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
|
||||
|
||||
# Build server
|
||||
WORKDIR /build
|
||||
RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
# Build frontend
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf dist elm-stuff node_modules
|
||||
RUN npm i
|
||||
RUN npm run build
|
||||
|
||||
# Run in minimal image
|
||||
FROM alpine:3.10
|
||||
FROM alpine:3.12
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=builder /build/inbucket bin
|
||||
@@ -36,6 +45,9 @@ ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
|
||||
|
||||
# Ports: SMTP, HTTP, POP3
|
||||
EXPOSE 2500 9000 1100
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -71,6 +72,7 @@ func main() {
|
||||
config.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// Process configuration.
|
||||
config.Version = version
|
||||
config.BuildDate = date
|
||||
@@ -83,6 +85,7 @@ func main() {
|
||||
conf.POP3.Debug = true
|
||||
conf.SMTP.Debug = true
|
||||
}
|
||||
|
||||
// Logger setup.
|
||||
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||
if err != nil {
|
||||
@@ -90,12 +93,15 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
startupLog := log.With().Str("phase", "startup").Logger()
|
||||
|
||||
// Setup signal handler.
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
// Initialize logging.
|
||||
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||
Msg("Inbucket starting")
|
||||
|
||||
// Write pidfile if requested.
|
||||
if *pidfile != "" {
|
||||
pidf, err := os.Create(*pidfile)
|
||||
@@ -107,6 +113,7 @@ func main() {
|
||||
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure internal services.
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
shutdownChan := make(chan bool)
|
||||
@@ -118,20 +125,26 @@ func main() {
|
||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
|
||||
// Start Retention scanner.
|
||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||
retentionScanner.Start()
|
||||
// Start HTTP server.
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
|
||||
// Configure routes and start HTTP server.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel.
|
||||
signalLoop:
|
||||
for {
|
||||
@@ -154,6 +167,7 @@ signalLoop:
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish.
|
||||
go timedExit(*pidfile)
|
||||
smtpServer.Drain()
|
||||
|
||||
@@ -28,6 +28,7 @@ variables it supports:
|
||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
|
||||
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||
@@ -231,7 +232,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
|
||||
### TLS Public Certificate File
|
||||
|
||||
`INBUCKET_SMTP_TLSPRIVKEY`
|
||||
`INBUCKET_SMTP_TLSCERT`
|
||||
|
||||
Specify the x509 Certificate file to be used for TLS negotiation.
|
||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
@@ -290,6 +291,24 @@ Inbucket to listen on all available network interfaces.
|
||||
|
||||
- Default: `0.0.0.0:9000`
|
||||
|
||||
### Base Path
|
||||
|
||||
`INBUCKET_WEB_BASEPATH`
|
||||
|
||||
Base path prefix for UI and API URLs. This option is used when you wish to
|
||||
root all Inbucket URLs to a specific path when placing it behind a
|
||||
reverse-proxy.
|
||||
|
||||
For example, setting the base path to `prefix` will move:
|
||||
- the Inbucket status page from `/status` to `/prefix/status`,
|
||||
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
|
||||
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
|
||||
|
||||
*Note:* This setting will not work correctly when running Inbucket via the npm
|
||||
development server.
|
||||
|
||||
- Default: None
|
||||
|
||||
### UI Directory
|
||||
|
||||
`INBUCKET_WEB_UIDIR`
|
||||
|
||||
@@ -13,6 +13,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||
#export INBUCKET_WEB_BASEPATH="prefix"
|
||||
export INBUCKET_STORAGE_TYPE="file"
|
||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# description: Launch Inbucket's docker image
|
||||
|
||||
# Docker Image Tag
|
||||
IMAGE="jhillyerd/inbucket"
|
||||
IMAGE="inbucket/inbucket"
|
||||
|
||||
# Ports exposed on host:
|
||||
PORT_HTTP=9000
|
||||
|
||||
30
go.mod
30
go.mod
@@ -2,20 +2,24 @@ module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-test/deep v1.0.2 // indirect
|
||||
github.com/google/subcommands v1.0.1
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/websocket v1.4.0
|
||||
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 // indirect
|
||||
github.com/jhillyerd/enmime v0.6.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
|
||||
github.com/jhillyerd/enmime v0.8.1
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/mattn/go-runewidth v0.0.4 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/olekukonko/tablewriter v0.0.1 // indirect
|
||||
github.com/rs/zerolog v1.15.0
|
||||
github.com/stretchr/testify v1.3.0
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.4
|
||||
github.com/olekukonko/tablewriter v0.0.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.19.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
|
||||
75
go.sum
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
|
||||
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
|
||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
|
||||
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.6.0 h1:FeypffI/uD1xt+Csd7gfD7mYx1h+qjgGlcI/ko5+LsI=
|
||||
github.com/jhillyerd/enmime v0.6.0/go.mod h1:lwWyVhHVBdmzXx3wtRTmpIdNEJyZ85LJuVqZHVK/Rlo=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.8.1 h1:Kz4xj3sJJ4Ju8e+w/7v9H4Matv5ijPgv7UkhPf+C15I=
|
||||
github.com/jhillyerd/enmime v0.8.1/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
|
||||
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
|
||||
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rs/zerolog v1.19.0 h1:hYz4ZVdUgjXTBUmrkrw55j1nHx68LfOKIQk5IYtyScg=
|
||||
github.com/rs/zerolog v1.19.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
|
||||
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -96,6 +96,7 @@ type POP3 struct {
|
||||
// Web contains the HTTP server configuration.
|
||||
type Web struct {
|
||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
|
||||
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||
|
||||
@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// not an EOF
|
||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||
// (?:) is non-grouping sub-match
|
||||
var fromRegex = regexp.MustCompile(
|
||||
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
||||
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
@@ -314,11 +314,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := policy.ParseEmailAddress(from); err != nil {
|
||||
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||
s.send("501 Bad sender address syntax")
|
||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
}
|
||||
if from == "" {
|
||||
from = "unspecified"
|
||||
}
|
||||
|
||||
// This is where the client may put BODY=8BITMIME, but we already
|
||||
// read the DATA as bytes, so it does not effect our processing.
|
||||
if m[2] != "" {
|
||||
@@ -433,6 +437,7 @@ func (s *Session) dataHandler() {
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||
@@ -527,12 +532,14 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// If we made it here, command is long enough to have args
|
||||
if line[4] != ' ' {
|
||||
// There wasn't a space after the command?
|
||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// I'm not sure if we should trim the args or not, but we will for now
|
||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||
}
|
||||
|
||||
@@ -79,6 +79,35 @@ func TestGreetState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestEmptyEnvelope(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
server, logbuf, teardown := setupSMTPServer(ds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some empty envelope without blanks
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Test out some empty envelope with blanks
|
||||
script = []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM: <>", 250},
|
||||
}
|
||||
if err := playSession(t, server, script); err != nil {
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestReadyState(t *testing.T) {
|
||||
ds := test.NewStore()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package web
|
||||
|
||||
type jsonAppConfig struct {
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
BasePath string `json:"base-path"`
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -82,3 +84,21 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
|
||||
func spaTemplateHandler(tmpl *template.Template, basePath string,
|
||||
webConfig config.Web) http.Handler {
|
||||
tmplData := struct {
|
||||
BasePath string
|
||||
}{
|
||||
BasePath: basePath,
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
err := tmpl.Execute(w, tmplData)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error rendering SPA index template")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -56,33 +59,59 @@ func Initialize(
|
||||
msgHub = mh
|
||||
manager = mm
|
||||
|
||||
// Redirect requests to / if there is a base path configured.
|
||||
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||
redirectBase := prefix("/")
|
||||
if redirectBase != "/" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||
Msg("Base path configured")
|
||||
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||
}
|
||||
|
||||
// Dynamic paths.
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.Handle("/debug/vars", expvar.Handler())
|
||||
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
|
||||
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Go pprof tools installed to /debug/pprof")
|
||||
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||
}
|
||||
|
||||
// Static paths.
|
||||
Router.PathPrefix("/static").Handler(
|
||||
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||
Router.Path("/favicon.png").Handler(
|
||||
Router.PathPrefix(prefix("/static")).Handler(
|
||||
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||
Router.Path(prefix("/favicon.png")).Handler(
|
||||
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||
|
||||
// Parse index.html template, allowing for configuration to be passed to the SPA.
|
||||
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
|
||||
indexTmpl, err := template.ParseFiles(indexPath)
|
||||
if err != nil {
|
||||
msg := "Failed to parse HTML template"
|
||||
cwd, _ := os.Getwd()
|
||||
log.Error().
|
||||
Str("module", "web").
|
||||
Str("phase", "startup").
|
||||
Str("path", indexPath).
|
||||
Str("cwd", cwd).
|
||||
Err(err).
|
||||
Msg(msg)
|
||||
// Create a dummy template to allow tests to pass.
|
||||
indexTmpl, _ = template.New("index.html").Parse(msg)
|
||||
}
|
||||
|
||||
// SPA managed paths.
|
||||
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||
fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
|
||||
Router.Path("/").Handler(spaHandler)
|
||||
Router.Path("/monitor").Handler(spaHandler)
|
||||
Router.Path("/status").Handler(spaHandler)
|
||||
Router.PathPrefix("/m/").Handler(spaHandler)
|
||||
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||
Router.Path(prefix("/")).Handler(spaHandler)
|
||||
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||
|
||||
// Error handlers.
|
||||
Router.NotFoundHandler = noMatchHandler(
|
||||
@@ -131,6 +160,7 @@ func Start(ctx context.Context) {
|
||||
|
||||
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||
o := &jsonAppConfig{
|
||||
BasePath: webConfig.BasePath,
|
||||
MonitorVisible: webConfig.MonitorVisible,
|
||||
}
|
||||
b, err := json.Marshal(o)
|
||||
|
||||
@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
|
||||
slice[i] = strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
|
||||
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
|
||||
// The returned prefixer expects all provided paths to start with /.
|
||||
func MakePathPrefixer(prefix string) func(string) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix != "" {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
|
||||
return func(path string) string {
|
||||
return prefix + path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stringutil_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
@@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakePathPrefixer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
prefix, path, want string
|
||||
}{
|
||||
{prefix: "", path: "", want: ""},
|
||||
{prefix: "", path: "relative", want: "relative"},
|
||||
{prefix: "", path: "/qualified", want: "/qualified"},
|
||||
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
|
||||
{prefix: "pfx", path: "", want: "/pfx"},
|
||||
{prefix: "pfx", path: "/", want: "/pfx/"},
|
||||
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "/pfx/", path: "", want: "/pfx"},
|
||||
{prefix: "/pfx/", path: "/", want: "/pfx/"},
|
||||
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
|
||||
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
|
||||
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||
{prefix: "a/b/c", path: "", want: "/a/b/c"},
|
||||
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
|
||||
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
|
||||
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
|
||||
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
|
||||
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
|
||||
prefixer := stringutil.MakePathPrefixer(tc.prefix)
|
||||
got := prefixer(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ stdenv.mkDerivation rec {
|
||||
buildInputs = [
|
||||
dpkg
|
||||
elmPackages.elm
|
||||
elmPackages.elm-analyse
|
||||
elmPackages.elm-format
|
||||
elmPackages.elm-language-server
|
||||
elmPackages.elm-test
|
||||
go
|
||||
golint
|
||||
nodejs-10_x
|
||||
|
||||
18
ui/elm.json
18
ui/elm.json
@@ -3,25 +3,25 @@
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.0",
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||
"basti1302/elm-human-readable-filesize": "1.1.1",
|
||||
"elm/browser": "1.0.1",
|
||||
"elm/core": "1.0.2",
|
||||
"basti1302/elm-human-readable-filesize": "1.2.0",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.2",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"jweir/sparkline": "4.0.0",
|
||||
"ryannhg/date-format": "2.1.0"
|
||||
"ryannhg/date-format": "2.3.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.3",
|
||||
"elm/file": "1.0.1",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"myrho/elm-round": "1.0.4"
|
||||
@@ -31,4 +31,4 @@
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
5133
ui/package-lock.json
generated
5133
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,21 +11,20 @@
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.5.5",
|
||||
"@babel/preset-env": "^7.5.5",
|
||||
"@fortawesome/fontawesome-free": "^5.10.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.2.10",
|
||||
"babel-loader": "^8.0.6",
|
||||
"css-loader": "^1.0.1",
|
||||
"elm": "^0.19.0-no-deps",
|
||||
"elm-hot-webpack-loader": "^1.1.1",
|
||||
"elm-webpack-loader": "^6.0.0",
|
||||
"file-loader": "^3.0.1",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"node-elm-compiler": "^5.0.4",
|
||||
"style-loader": "^0.23.1",
|
||||
"webpack": "^4.39.1",
|
||||
"webpack-cli": "^3.3.6",
|
||||
"webpack-dev-server": "^3.8.0"
|
||||
"@babel/core": "^7.11.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"@webcomponents/webcomponentsjs": "^2.4.4",
|
||||
"babel-loader": "^8.1.0",
|
||||
"css-loader": "^4.2.2",
|
||||
"elm-hot-webpack-loader": "^1.1.7",
|
||||
"elm-webpack-loader": "^7.0.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"html-webpack-plugin": "^4.4.1",
|
||||
"node-elm-compiler": "^5.0.5",
|
||||
"style-loader": "^1.2.1",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
21
ui/public/index-dev.html
Normal file
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="{{ .BasePath }}">
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
130
ui/src/Api.elm
130
ui/src/Api.elm
@@ -6,6 +6,7 @@ module Api exposing
|
||||
, getServerConfig
|
||||
, getServerMetrics
|
||||
, markMessageSeen
|
||||
, monitorUri
|
||||
, purgeMailbox
|
||||
, serveUrl
|
||||
)
|
||||
@@ -14,10 +15,12 @@ import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Metrics as Metrics exposing (Metrics)
|
||||
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
||||
import Data.Session exposing (Session)
|
||||
import Http
|
||||
import HttpUtil
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import String
|
||||
import Url.Builder
|
||||
|
||||
|
||||
@@ -29,31 +32,17 @@ type alias HttpResult msg =
|
||||
Result HttpUtil.Error () -> msg
|
||||
|
||||
|
||||
{-| Builds a public REST API URL (see wiki).
|
||||
-}
|
||||
apiV1Url : List String -> String
|
||||
apiV1Url elements =
|
||||
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
|
||||
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||
deleteMessage session msg mailboxName id =
|
||||
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||
|
||||
|
||||
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
||||
-}
|
||||
serveUrl : List String -> String
|
||||
serveUrl elements =
|
||||
Url.Builder.absolute ([ "serve" ] ++ elements) []
|
||||
|
||||
|
||||
deleteMessage : HttpResult msg -> String -> String -> Cmd msg
|
||||
deleteMessage msg mailboxName id =
|
||||
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName, id ])
|
||||
|
||||
|
||||
getHeaderList : DataResult msg (List MessageHeader) -> String -> Cmd msg
|
||||
getHeaderList msg mailboxName =
|
||||
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
|
||||
getHeaderList session msg mailboxName =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = apiV1Url [ "mailbox", mailboxName ]
|
||||
, url = apiV1Url session [ "mailbox", mailboxName ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
@@ -62,12 +51,12 @@ getHeaderList msg mailboxName =
|
||||
}
|
||||
|
||||
|
||||
getGreeting : DataResult msg String -> Cmd msg
|
||||
getGreeting msg =
|
||||
getGreeting : Session -> DataResult msg String -> Cmd msg
|
||||
getGreeting session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "greeting" ]
|
||||
, url = serveUrl session [ "greeting" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
@@ -76,12 +65,12 @@ getGreeting msg =
|
||||
}
|
||||
|
||||
|
||||
getMessage : DataResult msg Message -> String -> String -> Cmd msg
|
||||
getMessage msg mailboxName id =
|
||||
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
|
||||
getMessage session msg mailboxName id =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "mailbox", mailboxName, id ]
|
||||
, url = serveUrl session [ "mailbox", mailboxName, id ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
@@ -90,12 +79,12 @@ getMessage msg mailboxName id =
|
||||
}
|
||||
|
||||
|
||||
getServerConfig : DataResult msg ServerConfig -> Cmd msg
|
||||
getServerConfig msg =
|
||||
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
|
||||
getServerConfig session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "status" ]
|
||||
, url = serveUrl session [ "status" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
@@ -104,12 +93,19 @@ getServerConfig msg =
|
||||
}
|
||||
|
||||
|
||||
getServerMetrics : DataResult msg Metrics -> Cmd msg
|
||||
getServerMetrics msg =
|
||||
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
|
||||
getServerMetrics session msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = Url.Builder.absolute [ "debug", "vars" ] []
|
||||
, url =
|
||||
Url.Builder.absolute
|
||||
(splitBasePath session.config.basePath
|
||||
++ [ "debug"
|
||||
, "vars"
|
||||
]
|
||||
)
|
||||
[]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
@@ -118,15 +114,73 @@ getServerMetrics msg =
|
||||
}
|
||||
|
||||
|
||||
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg
|
||||
markMessageSeen msg mailboxName id =
|
||||
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||
markMessageSeen session msg mailboxName id =
|
||||
-- The URL tells the API which message ID to update, so we only need to indicate the
|
||||
-- desired change in the body.
|
||||
Encode.object [ ( "seen", Encode.bool True ) ]
|
||||
|> Http.jsonBody
|
||||
|> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ])
|
||||
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||
|
||||
|
||||
purgeMailbox : HttpResult msg -> String -> Cmd msg
|
||||
purgeMailbox msg mailboxName =
|
||||
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ])
|
||||
monitorUri : Session -> String
|
||||
monitorUri session =
|
||||
apiV1Url session [ "monitor", "messages" ]
|
||||
|
||||
|
||||
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
|
||||
purgeMailbox session msg mailboxName =
|
||||
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
|
||||
|
||||
|
||||
{-| Builds a public REST API URL (see wiki).
|
||||
-}
|
||||
apiV1Url : Session -> List String -> String
|
||||
apiV1Url session elements =
|
||||
Url.Builder.absolute
|
||||
(List.concat
|
||||
[ splitBasePath session.config.basePath
|
||||
, [ "api", "v1" ]
|
||||
, elements
|
||||
]
|
||||
)
|
||||
[]
|
||||
|
||||
|
||||
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
||||
-}
|
||||
serveUrl : Session -> List String -> String
|
||||
serveUrl session elements =
|
||||
Url.Builder.absolute
|
||||
(List.concat
|
||||
[ splitBasePath session.config.basePath
|
||||
, [ "serve" ]
|
||||
, elements
|
||||
]
|
||||
)
|
||||
[]
|
||||
|
||||
|
||||
{-| Converts base path into a list of path elements.
|
||||
-}
|
||||
splitBasePath : String -> List String
|
||||
splitBasePath path =
|
||||
if path == "" then
|
||||
[]
|
||||
|
||||
else
|
||||
let
|
||||
stripSlashes str =
|
||||
if String.startsWith "/" str then
|
||||
stripSlashes (String.dropLeft 1 str)
|
||||
|
||||
else if String.endsWith "/" str then
|
||||
stripSlashes (String.dropRight 1 str)
|
||||
|
||||
else
|
||||
str
|
||||
|
||||
newPath =
|
||||
stripSlashes path
|
||||
in
|
||||
String.split "/" newPath
|
||||
|
||||
@@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P
|
||||
|
||||
|
||||
type alias AppConfig =
|
||||
{ monitorVisible : Bool
|
||||
{ basePath : String
|
||||
, monitorVisible : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : D.Decoder AppConfig
|
||||
decoder =
|
||||
D.succeed AppConfig
|
||||
|> P.optional "base-path" D.string ""
|
||||
|> P.required "monitor-visible" D.bool
|
||||
|
||||
|
||||
default : AppConfig
|
||||
default =
|
||||
AppConfig True
|
||||
AppConfig "" True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Data.Date exposing (date)
|
||||
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode exposing (Decoder, int, map)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (optional, required)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module Data.MessageHeader exposing (MessageHeader, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (optional, required)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Json.Decode exposing (Decoder, int, map, string, succeed)
|
||||
import Json.Decode.Pipeline exposing (requiredAt)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@ module Data.Session exposing
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Json.Decode.Pipeline exposing (optional)
|
||||
import Json.Encode as E
|
||||
import Ports
|
||||
import Route exposing (Router)
|
||||
import Time
|
||||
import Url exposing (Url)
|
||||
|
||||
@@ -29,6 +28,7 @@ type alias Session =
|
||||
, host : String
|
||||
, flash : Maybe Flash
|
||||
, routing : Bool
|
||||
, router : Router
|
||||
, zone : Time.Zone
|
||||
, config : AppConfig
|
||||
, persistent : Persistent
|
||||
@@ -52,6 +52,7 @@ init key location config persistent =
|
||||
, host = location.host
|
||||
, flash = Nothing
|
||||
, routing = True
|
||||
, router = Route.newRouter config.basePath
|
||||
, zone = Time.utc
|
||||
, config = config
|
||||
, persistent = persistent
|
||||
@@ -64,6 +65,7 @@ initError key location error =
|
||||
, host = location.host
|
||||
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
||||
, routing = True
|
||||
, router = Route.newRouter ""
|
||||
, zone = Time.utc
|
||||
, config = AppConfig.default
|
||||
, persistent = Persistent []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
|
||||
|
||||
import Data.Session as Session
|
||||
import Html exposing (Html, div, text)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Html exposing (..)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, a
|
||||
, button
|
||||
, div
|
||||
, footer
|
||||
, form
|
||||
, h2
|
||||
, header
|
||||
, i
|
||||
, input
|
||||
, li
|
||||
, nav
|
||||
, pre
|
||||
, span
|
||||
, td
|
||||
, text
|
||||
, th
|
||||
, tr
|
||||
, ul
|
||||
)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( attribute
|
||||
, class
|
||||
, classList
|
||||
, href
|
||||
, id
|
||||
, placeholder
|
||||
, rel
|
||||
, selected
|
||||
, target
|
||||
, type_
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import Modal
|
||||
import Route exposing (Route)
|
||||
import Timer exposing (Timer)
|
||||
|
||||
|
||||
{-| Used to highlight current page in navbar.
|
||||
@@ -31,8 +54,9 @@ type Page
|
||||
|
||||
type alias Model msg =
|
||||
{ mapMsg : Msg -> msg
|
||||
, menuVisible : Bool
|
||||
, recentVisible : Bool
|
||||
, mainMenuVisible : Bool
|
||||
, recentMenuVisible : Bool
|
||||
, recentMenuTimer : Timer
|
||||
, mailboxName : String
|
||||
}
|
||||
|
||||
@@ -40,8 +64,9 @@ type alias Model msg =
|
||||
init : (Msg -> msg) -> Model msg
|
||||
init mapMsg =
|
||||
{ mapMsg = mapMsg
|
||||
, menuVisible = False
|
||||
, recentVisible = False
|
||||
, mainMenuVisible = False
|
||||
, recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.empty
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
@@ -51,18 +76,24 @@ init mapMsg =
|
||||
reset : Model msg -> Model msg
|
||||
reset model =
|
||||
{ model
|
||||
| menuVisible = False
|
||||
, recentVisible = False
|
||||
| mainMenuVisible = False
|
||||
, recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= ClearFlash
|
||||
| MainMenuToggled
|
||||
| ModalFocused Modal.Msg
|
||||
| ModalUnfocused
|
||||
| OnMailboxNameInput String
|
||||
| OpenMailbox
|
||||
| ShowRecent Bool
|
||||
| ToggleMenu
|
||||
| RecentMenuMouseOver
|
||||
| RecentMenuMouseOut
|
||||
| RecentMenuTimeout Timer
|
||||
| RecentMenuToggled
|
||||
|
||||
|
||||
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
|
||||
@@ -74,6 +105,21 @@ update msg model session =
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MainMenuToggled ->
|
||||
( { model | mainMenuVisible = not model.mainMenuVisible }
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ModalFocused message ->
|
||||
( model
|
||||
, Modal.updateSession message session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ModalUnfocused ->
|
||||
( model, session, Modal.resetFocusCmd (ModalFocused >> model.mapMsg) )
|
||||
|
||||
OnMailboxNameInput name ->
|
||||
( { model | mailboxName = name }
|
||||
, session
|
||||
@@ -87,17 +133,48 @@ update msg model session =
|
||||
else
|
||||
( model
|
||||
, session
|
||||
, Route.pushUrl session.key (Route.Mailbox model.mailboxName)
|
||||
, Route.Mailbox model.mailboxName
|
||||
|> session.router.toPath
|
||||
|> Nav.pushUrl session.key
|
||||
)
|
||||
|
||||
ShowRecent visible ->
|
||||
( { model | recentVisible = visible }
|
||||
RecentMenuMouseOver ->
|
||||
( { model
|
||||
| recentMenuVisible = True
|
||||
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||
}
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ToggleMenu ->
|
||||
( { model | menuVisible = not model.menuVisible }
|
||||
RecentMenuMouseOut ->
|
||||
let
|
||||
newTimer =
|
||||
Timer.replace model.recentMenuTimer
|
||||
in
|
||||
( { model
|
||||
| recentMenuTimer = newTimer
|
||||
}
|
||||
, session
|
||||
, Timer.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
|
||||
)
|
||||
|
||||
RecentMenuTimeout timer ->
|
||||
if timer == model.recentMenuTimer then
|
||||
( { model
|
||||
| recentMenuVisible = False
|
||||
, recentMenuTimer = Timer.cancel timer
|
||||
}
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
else
|
||||
-- Timer was no longer valid.
|
||||
( model, session, Cmd.none )
|
||||
|
||||
RecentMenuToggled ->
|
||||
( { model | recentMenuVisible = not model.recentMenuVisible }
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
@@ -118,17 +195,17 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
||||
div [ class "app" ]
|
||||
[ header []
|
||||
[ nav [ class "navbar" ]
|
||||
[ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> model.mapMsg) ]
|
||||
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
|
||||
[ i [ class "fas fa-bars" ] [] ]
|
||||
, span [ class "navbar-brand" ]
|
||||
[ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
|
||||
, ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ]
|
||||
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
|
||||
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
|
||||
[ if session.config.monitorVisible then
|
||||
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage
|
||||
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
|
||||
|
||||
else
|
||||
text ""
|
||||
, navbarLink Status Route.Status [ text "Status" ] activePage
|
||||
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
|
||||
, navbarRecent activePage activeMailbox model session
|
||||
, li [ class "navbar-mailbox" ]
|
||||
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
||||
@@ -145,8 +222,8 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
||||
]
|
||||
]
|
||||
, div [ class "navbar-bg" ] [ text "" ]
|
||||
, frameModal modal
|
||||
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content)
|
||||
, Modal.view (ModalUnfocused |> model.mapMsg) modal
|
||||
, div [ class "page" ] (errorFlash model session.flash :: content)
|
||||
, footer []
|
||||
[ div [ class "footer" ]
|
||||
[ externalLink "https://www.inbucket.org" "Inbucket"
|
||||
@@ -158,18 +235,6 @@ frame { model, session, activePage, activeMailbox, modal, content } =
|
||||
]
|
||||
|
||||
|
||||
frameModal : Maybe (Html msg) -> Html msg
|
||||
frameModal maybeModal =
|
||||
case maybeModal of
|
||||
Just modal ->
|
||||
div [ class "modal-mask" ]
|
||||
[ div [ class "modal well" ] [ modal ]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
|
||||
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
|
||||
errorFlash model maybeFlash =
|
||||
let
|
||||
@@ -198,10 +263,10 @@ externalLink url title =
|
||||
a [ href url, target "_blank", rel "noopener" ] [ text title ]
|
||||
|
||||
|
||||
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a
|
||||
navbarLink page route linkContent activePage =
|
||||
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
|
||||
navbarLink page url linkContent activePage =
|
||||
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
||||
[ a [ Route.href route ] linkContent ]
|
||||
[ a [ href url ] linkContent ]
|
||||
|
||||
|
||||
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||
@@ -229,29 +294,22 @@ navbarRecent page activeMailbox model session =
|
||||
else
|
||||
session.persistent.recentMailboxes
|
||||
|
||||
dropdownExpanded =
|
||||
if model.recentVisible then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
|
||||
recentLink mailbox =
|
||||
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
|
||||
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
|
||||
in
|
||||
li
|
||||
[ class "navbar-dropdown-container"
|
||||
, classList [ ( "navbar-active", active ) ]
|
||||
, attribute "aria-haspopup" "true"
|
||||
, ariaExpanded model.recentVisible
|
||||
, Events.onMouseOver (ShowRecent True |> model.mapMsg)
|
||||
, Events.onMouseOut (ShowRecent False |> model.mapMsg)
|
||||
, ariaExpanded model.recentMenuVisible
|
||||
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
|
||||
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
|
||||
]
|
||||
[ span [ class "navbar-dropdown" ]
|
||||
[ text title
|
||||
, button
|
||||
[ class "navbar-dropdown-button"
|
||||
, Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg)
|
||||
, Events.onClick (RecentMenuToggled |> model.mapMsg)
|
||||
]
|
||||
[ i [ class "fas fa-chevron-down" ] [] ]
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import Browser exposing (Document, UrlRequest)
|
||||
import Browser.Navigation as Nav
|
||||
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Html exposing (..)
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as D exposing (Value)
|
||||
import Layout
|
||||
import Page.Home as Home
|
||||
@@ -66,7 +66,7 @@ init configValue location key =
|
||||
}
|
||||
|
||||
route =
|
||||
Route.fromUrl location
|
||||
session.router.fromUrl location
|
||||
|
||||
( model, cmd ) =
|
||||
changeRouteTo route initModel
|
||||
@@ -167,7 +167,7 @@ updateMain msg model session =
|
||||
UrlChanged url ->
|
||||
-- Responds to new browser URL.
|
||||
if session.routing then
|
||||
changeRouteTo (Route.fromUrl url) model
|
||||
changeRouteTo (session.router.fromUrl url) model
|
||||
|
||||
else
|
||||
-- Skip once, but re-enable routing.
|
||||
|
||||
58
ui/src/Modal.elm
Normal file
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 Data.Session as Session exposing (Session)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Http
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes exposing (class, property)
|
||||
import HttpUtil
|
||||
import Json.Encode as Encode
|
||||
import Ports
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +20,7 @@ type alias Model =
|
||||
|
||||
init : Session -> ( Model, Cmd Msg )
|
||||
init session =
|
||||
( Model session "", Api.getGreeting GreetingLoaded )
|
||||
( Model session "", Api.getGreeting session GreetingLoaded )
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view)
|
||||
|
||||
import Api
|
||||
import Browser.Navigation as Nav
|
||||
import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import DateFormat.Relative as Relative
|
||||
import Html exposing (..)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, a
|
||||
, article
|
||||
, aside
|
||||
, button
|
||||
, dd
|
||||
, div
|
||||
, dl
|
||||
, dt
|
||||
, h3
|
||||
, i
|
||||
, input
|
||||
, li
|
||||
, main_
|
||||
, nav
|
||||
, p
|
||||
, span
|
||||
, table
|
||||
, td
|
||||
, text
|
||||
, tr
|
||||
, ul
|
||||
)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( alt
|
||||
@@ -15,7 +41,6 @@ import Html.Attributes
|
||||
, disabled
|
||||
, download
|
||||
, href
|
||||
, id
|
||||
, placeholder
|
||||
, property
|
||||
, tabindex
|
||||
@@ -24,14 +49,14 @@ import Html.Attributes
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Json.Decode as D
|
||||
import Json.Encode as E
|
||||
import Ports
|
||||
import Modal
|
||||
import Route
|
||||
import Task
|
||||
import Time exposing (Posix)
|
||||
import Timer exposing (Timer)
|
||||
|
||||
|
||||
|
||||
@@ -51,8 +76,8 @@ type State
|
||||
type MessageState
|
||||
= NoMessage
|
||||
| LoadingMessage
|
||||
| ShowingMessage VisibleMessage
|
||||
| Transitioning VisibleMessage
|
||||
| ShowingMessage Message
|
||||
| Transitioning Message
|
||||
|
||||
|
||||
type alias MessageID =
|
||||
@@ -66,12 +91,6 @@ type alias MessageList =
|
||||
}
|
||||
|
||||
|
||||
type alias VisibleMessage =
|
||||
{ message : Message
|
||||
, markSeenAt : Maybe Int
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, mailboxName : String
|
||||
@@ -79,6 +98,7 @@ type alias Model =
|
||||
, bodyMode : Body
|
||||
, searchInput : String
|
||||
, promptPurge : Bool
|
||||
, markSeenTimer : Timer
|
||||
, now : Posix
|
||||
}
|
||||
|
||||
@@ -91,17 +111,18 @@ init session mailboxName selection =
|
||||
, bodyMode = SafeHtmlBody
|
||||
, searchInput = ""
|
||||
, promptPurge = False
|
||||
, markSeenTimer = Timer.empty
|
||||
, now = Time.millisToPosix 0
|
||||
}
|
||||
, load mailboxName
|
||||
, load session mailboxName
|
||||
)
|
||||
|
||||
|
||||
load : String -> Cmd Msg
|
||||
load mailboxName =
|
||||
load : Session -> String -> Cmd Msg
|
||||
load session mailboxName =
|
||||
Cmd.batch
|
||||
[ Task.perform Tick Time.now
|
||||
, Api.getHeaderList ListLoaded mailboxName
|
||||
, Api.getHeaderList session ListLoaded mailboxName
|
||||
]
|
||||
|
||||
|
||||
@@ -110,24 +131,8 @@ load mailboxName =
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
let
|
||||
subSeen =
|
||||
case model.state of
|
||||
ShowingList _ (ShowingMessage { message }) ->
|
||||
if message.seen then
|
||||
Sub.none
|
||||
|
||||
else
|
||||
Time.every 250 MarkSeenTick
|
||||
|
||||
_ ->
|
||||
Sub.none
|
||||
in
|
||||
Sub.batch
|
||||
[ Time.every (30 * 1000) Tick
|
||||
, subSeen
|
||||
]
|
||||
subscriptions _ =
|
||||
Time.every (30 * 1000) Tick
|
||||
|
||||
|
||||
|
||||
@@ -138,13 +143,11 @@ type Msg
|
||||
= ListLoaded (Result HttpUtil.Error (List MessageHeader))
|
||||
| ClickMessage MessageID
|
||||
| ListKeyPress String Int
|
||||
| OpenMessage MessageID
|
||||
| CloseMessage
|
||||
| MessageLoaded (Result HttpUtil.Error Message)
|
||||
| MessageBody Body
|
||||
| OpenedTime Posix
|
||||
| MarkSeenTick Posix
|
||||
| MarkedSeen (Result HttpUtil.Error ())
|
||||
| MarkSeenTriggered Timer
|
||||
| MarkSeenLoaded (Result HttpUtil.Error ())
|
||||
| DeleteMessage Message
|
||||
| DeletedMessage (Result HttpUtil.Error ())
|
||||
| PurgeMailboxPrompt
|
||||
@@ -153,6 +156,7 @@ type Msg
|
||||
| PurgedMailbox (Result HttpUtil.Error ())
|
||||
| OnSearchInput String
|
||||
| Tick Posix
|
||||
| ModalFocused Modal.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
@@ -162,14 +166,13 @@ update msg model =
|
||||
( updateSelected { model | session = Session.disableRouting model.session } id
|
||||
, Cmd.batch
|
||||
[ -- Update browser location.
|
||||
Route.replaceUrl model.session.key (Route.Message model.mailboxName id)
|
||||
, Api.getMessage MessageLoaded model.mailboxName id
|
||||
Route.Message model.mailboxName id
|
||||
|> model.session.router.toPath
|
||||
|> Nav.replaceUrl model.session.key
|
||||
, Api.getMessage model.session MessageLoaded model.mailboxName id
|
||||
]
|
||||
)
|
||||
|
||||
OpenMessage id ->
|
||||
updateOpenMessage model id
|
||||
|
||||
CloseMessage ->
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
@@ -225,10 +228,10 @@ update msg model =
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MarkedSeen (Ok _) ->
|
||||
MarkSeenLoaded (Ok _) ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MarkedSeen (Err err) ->
|
||||
MarkSeenLoaded (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
@@ -244,44 +247,22 @@ update msg model =
|
||||
MessageBody bodyMode ->
|
||||
( { model | bodyMode = bodyMode }, Cmd.none )
|
||||
|
||||
ModalFocused message ->
|
||||
( { model | session = Modal.updateSession message model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
OnSearchInput searchInput ->
|
||||
updateSearchInput model searchInput
|
||||
|
||||
OpenedTime time ->
|
||||
case model.state of
|
||||
ShowingList list (ShowingMessage visible) ->
|
||||
if visible.message.seen then
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
-- Set 1500ms delay before reporting message as seen to backend.
|
||||
let
|
||||
markSeenAt =
|
||||
Time.posixToMillis time + 1500
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList list
|
||||
(ShowingMessage
|
||||
{ visible
|
||||
| markSeenAt = Just markSeenAt
|
||||
}
|
||||
)
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
PurgeMailboxPrompt ->
|
||||
( { model | promptPurge = True }, Cmd.none )
|
||||
( { model | promptPurge = True }, Modal.resetFocusCmd ModalFocused )
|
||||
|
||||
PurgeMailboxCanceled ->
|
||||
( { model | promptPurge = False }, Cmd.none )
|
||||
|
||||
PurgeMailboxConfirmed ->
|
||||
updatePurge model
|
||||
updateTriggerPurge model
|
||||
|
||||
PurgedMailbox (Ok _) ->
|
||||
( model, Cmd.none )
|
||||
@@ -291,22 +272,13 @@ update msg model =
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MarkSeenTick now ->
|
||||
case model.state of
|
||||
ShowingList _ (ShowingMessage { message, markSeenAt }) ->
|
||||
case markSeenAt of
|
||||
Just deadline ->
|
||||
if Time.posixToMillis now >= deadline then
|
||||
updateMarkMessageSeen model message
|
||||
MarkSeenTriggered timer ->
|
||||
if timer == model.markSeenTimer then
|
||||
-- Matching timer means we have changed messages, mark this one seen.
|
||||
updateMarkMessageSeen model
|
||||
|
||||
else
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
else
|
||||
( model, Cmd.none )
|
||||
|
||||
Tick now ->
|
||||
( { model | now = now }, Cmd.none )
|
||||
@@ -329,28 +301,38 @@ updateMessageResult model message =
|
||||
( model, Cmd.none )
|
||||
|
||||
ShowingList list _ ->
|
||||
let
|
||||
newTimer =
|
||||
Timer.replace model.markSeenTimer
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList
|
||||
{ list | selected = Just message.id }
|
||||
(ShowingMessage (VisibleMessage message Nothing))
|
||||
(ShowingMessage message)
|
||||
, bodyMode = bodyMode
|
||||
, markSeenTimer = newTimer
|
||||
}
|
||||
, Task.perform OpenedTime Time.now
|
||||
-- Set 1500ms delay before reporting message as seen to backend.
|
||||
, Timer.schedule MarkSeenTriggered newTimer 1500
|
||||
)
|
||||
|
||||
|
||||
updatePurge : Model -> ( Model, Cmd Msg )
|
||||
updatePurge model =
|
||||
{-| Updates model and triggers commands to purge this mailbox.
|
||||
-}
|
||||
updateTriggerPurge : Model -> ( Model, Cmd Msg )
|
||||
updateTriggerPurge model =
|
||||
let
|
||||
cmd =
|
||||
Cmd.batch
|
||||
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
|
||||
, Api.purgeMailbox PurgedMailbox model.mailboxName
|
||||
[ Route.Mailbox model.mailboxName
|
||||
|> model.session.router.toPath
|
||||
|> Nav.replaceUrl model.session.key
|
||||
, Api.purgeMailbox model.session PurgedMailbox model.mailboxName
|
||||
]
|
||||
in
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
ShowingList _ _ ->
|
||||
( { model
|
||||
| promptPurge = False
|
||||
, session = Session.disableRouting model.session
|
||||
@@ -428,8 +410,10 @@ updateDeleteMessage model message =
|
||||
ShowingList (filter (\x -> x.id /= message.id) list) NoMessage
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.deleteMessage DeletedMessage message.mailbox message.id
|
||||
, Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
|
||||
[ Api.deleteMessage model.session DeletedMessage message.mailbox message.id
|
||||
, Route.Mailbox model.mailboxName
|
||||
|> model.session.router.toPath
|
||||
|> Nav.replaceUrl model.session.key
|
||||
]
|
||||
)
|
||||
|
||||
@@ -437,32 +421,28 @@ updateDeleteMessage model message =
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
updateMarkMessageSeen : Model -> Message -> ( Model, Cmd Msg )
|
||||
updateMarkMessageSeen model message =
|
||||
{-| Updates both the active message, and the message list to mark the currently viewed message as seen.
|
||||
-}
|
||||
updateMarkMessageSeen : Model -> ( Model, Cmd Msg )
|
||||
updateMarkMessageSeen model =
|
||||
case model.state of
|
||||
ShowingList list (ShowingMessage visible) ->
|
||||
ShowingList messages (ShowingMessage visibleMessage) ->
|
||||
let
|
||||
updateSeen header =
|
||||
if header.id == message.id then
|
||||
updateHeader header =
|
||||
if header.id == visibleMessage.id then
|
||||
{ header | seen = True }
|
||||
|
||||
else
|
||||
header
|
||||
|
||||
map f messageList =
|
||||
{ messageList | headers = List.map f messageList.headers }
|
||||
newMessages =
|
||||
{ messages | headers = List.map updateHeader messages.headers }
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList (map updateSeen list)
|
||||
(ShowingMessage
|
||||
{ visible
|
||||
| message = { message | seen = True }
|
||||
, markSeenAt = Nothing
|
||||
}
|
||||
)
|
||||
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
|
||||
}
|
||||
, Api.markMessageSeen MarkedSeen message.mailbox message.id
|
||||
, Api.markMessageSeen model.session MarkSeenLoaded visibleMessage.mailbox visibleMessage.id
|
||||
)
|
||||
|
||||
_ ->
|
||||
@@ -476,7 +456,7 @@ updateOpenMessage model id =
|
||||
{ model | session = Session.addRecent model.mailboxName model.session }
|
||||
in
|
||||
( updateSelected newModel id
|
||||
, Api.getMessage MessageLoaded model.mailboxName id
|
||||
, Api.getMessage model.session MessageLoaded model.mailboxName id
|
||||
)
|
||||
|
||||
|
||||
@@ -529,11 +509,11 @@ view model =
|
||||
++ " or enter a different username into the box on upper right."
|
||||
)
|
||||
|
||||
ShowingList _ (ShowingMessage { message }) ->
|
||||
viewMessage model.session.zone message model.bodyMode
|
||||
ShowingList _ (ShowingMessage message) ->
|
||||
viewMessage model.session model.session.zone message model.bodyMode
|
||||
|
||||
ShowingList _ (Transitioning { message }) ->
|
||||
viewMessage model.session.zone message model.bodyMode
|
||||
ShowingList _ (Transitioning message) ->
|
||||
viewMessage model.session model.session.zone message model.bodyMode
|
||||
|
||||
_ ->
|
||||
text ""
|
||||
@@ -591,14 +571,14 @@ messageChip model selected message =
|
||||
]
|
||||
|
||||
|
||||
viewMessage : Time.Zone -> Message -> Body -> Html Msg
|
||||
viewMessage zone message bodyMode =
|
||||
viewMessage : Session -> Time.Zone -> Message -> Body -> Html Msg
|
||||
viewMessage session zone message bodyMode =
|
||||
let
|
||||
htmlUrl =
|
||||
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
|
||||
Api.serveUrl session [ "mailbox", message.mailbox, message.id, "html" ]
|
||||
|
||||
sourceUrl =
|
||||
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
|
||||
Api.serveUrl session [ "mailbox", message.mailbox, message.id, "source" ]
|
||||
|
||||
htmlButton =
|
||||
if message.html == "" then
|
||||
@@ -629,7 +609,7 @@ viewMessage zone message bodyMode =
|
||||
]
|
||||
, messageErrors message
|
||||
, messageBody message bodyMode
|
||||
, attachments message
|
||||
, attachments session message
|
||||
]
|
||||
|
||||
|
||||
@@ -692,20 +672,20 @@ messageBody message bodyMode =
|
||||
]
|
||||
|
||||
|
||||
attachments : Message -> Html Msg
|
||||
attachments message =
|
||||
attachments : Session -> Message -> Html Msg
|
||||
attachments session message =
|
||||
if List.isEmpty message.attachments then
|
||||
div [] []
|
||||
|
||||
else
|
||||
table [ class "attachments well" ] (List.map (attachmentRow message) message.attachments)
|
||||
table [ class "attachments well" ] (List.map (attachmentRow session message) message.attachments)
|
||||
|
||||
|
||||
attachmentRow : Message -> Message.Attachment -> Html Msg
|
||||
attachmentRow message attach =
|
||||
attachmentRow : Session -> Message -> Message.Attachment -> Html Msg
|
||||
attachmentRow session message attach =
|
||||
let
|
||||
url =
|
||||
Api.serveUrl
|
||||
Api.serveUrl session
|
||||
[ "mailbox"
|
||||
, message.mailbox
|
||||
, message.id
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
module Page.Monitor exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Api
|
||||
import Browser.Navigation as Nav
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html
|
||||
exposing
|
||||
( Attribute
|
||||
, Html
|
||||
, button
|
||||
, div
|
||||
, em
|
||||
, h1
|
||||
, node
|
||||
, span
|
||||
, table
|
||||
, tbody
|
||||
, td
|
||||
, text
|
||||
, th
|
||||
, thead
|
||||
, tr
|
||||
)
|
||||
import Html.Attributes exposing (class, src, tabindex)
|
||||
import Html.Events as Events
|
||||
import Json.Decode as D
|
||||
import Route
|
||||
@@ -84,7 +103,9 @@ update msg model =
|
||||
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
|
||||
openMessage header model =
|
||||
( model
|
||||
, Route.pushUrl model.session.key (Route.Message header.mailbox header.id)
|
||||
, Route.Message header.mailbox header.id
|
||||
|> model.session.router.toPath
|
||||
|> Nav.replaceUrl model.session.key
|
||||
)
|
||||
|
||||
|
||||
@@ -115,8 +136,12 @@ view model =
|
||||
[ button [ Events.onClick Clear ] [ text "Clear" ]
|
||||
]
|
||||
]
|
||||
|
||||
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
|
||||
-- specified by `src`.
|
||||
, node "monitor-messages"
|
||||
[ Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
|
||||
[ src (Api.monitorUri model.session)
|
||||
, Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
|
||||
, Events.on "message" (D.map MessageReceived D.value)
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
|
||||
|
||||
import Api
|
||||
import Data.Metrics as Metrics exposing (Metrics)
|
||||
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
||||
import Data.Metrics exposing (Metrics)
|
||||
import Data.ServerConfig exposing (ServerConfig)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import DateFormat.Relative as Relative
|
||||
import Filesize
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Http exposing (Error)
|
||||
import Html
|
||||
exposing
|
||||
( Html
|
||||
, div
|
||||
, h1
|
||||
, h2
|
||||
, i
|
||||
, text
|
||||
)
|
||||
import Html.Attributes exposing (class)
|
||||
import HttpUtil
|
||||
import Sparkline as Spark
|
||||
import Svg.Attributes as SvgAttrib
|
||||
@@ -77,7 +84,7 @@ init session =
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Task.perform Tick Time.now
|
||||
, Api.getServerConfig ServerConfigLoaded
|
||||
, Api.getServerConfig session ServerConfigLoaded
|
||||
]
|
||||
)
|
||||
|
||||
@@ -93,7 +100,7 @@ initDataSet =
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
subscriptions _ =
|
||||
Time.every (10 * 1000) Tick
|
||||
|
||||
|
||||
@@ -127,7 +134,7 @@ update msg model =
|
||||
)
|
||||
|
||||
Tick time ->
|
||||
( { model | now = time }, Api.getServerMetrics MetricsReceived )
|
||||
( { model | now = time }, Api.getServerMetrics model.session MetricsReceived )
|
||||
|
||||
|
||||
{-| Update all metrics in Model; increment xCounter.
|
||||
@@ -271,18 +278,19 @@ configPanel maybeConfig =
|
||||
, textEntry "SMTP Listener" config.smtpConfig.addr
|
||||
, textEntry "POP3 Listener" config.pop3Listener
|
||||
, textEntry "HTTP Listener" config.webListener
|
||||
, textEntry "Accept Policy" (acceptPolicy config.smtpConfig)
|
||||
, textEntry "Store Policy" (storePolicy config.smtpConfig)
|
||||
, textEntry "Accept Policy" (acceptPolicy config)
|
||||
, textEntry "Store Policy" (storePolicy config)
|
||||
, textEntry "Store Type" config.storageConfig.storeType
|
||||
, textEntry "Message Cap" (mailboxCap config)
|
||||
, textEntry "Retention Period" (retentionPeriod config)
|
||||
]
|
||||
|
||||
|
||||
acceptPolicy : ServerConfig -> String
|
||||
acceptPolicy config =
|
||||
if config.defaultAccept then
|
||||
if config.smtpConfig.defaultAccept then
|
||||
"All domains"
|
||||
++ (case config.rejectDomains of
|
||||
++ (case config.smtpConfig.rejectDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
@@ -295,7 +303,7 @@ acceptPolicy config =
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.acceptDomains of
|
||||
++ (case config.smtpConfig.acceptDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
@@ -307,10 +315,11 @@ acceptPolicy config =
|
||||
)
|
||||
|
||||
|
||||
storePolicy : ServerConfig -> String
|
||||
storePolicy config =
|
||||
if config.defaultStore then
|
||||
if config.smtpConfig.defaultStore then
|
||||
"All domains"
|
||||
++ (case config.discardDomains of
|
||||
++ (case config.smtpConfig.discardDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
@@ -323,7 +332,7 @@ storePolicy config =
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.storeDomains of
|
||||
++ (case config.smtpConfig.storeDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
@@ -412,23 +421,6 @@ viewMetric metric =
|
||||
]
|
||||
|
||||
|
||||
viewLiveMetric : String -> (Int -> String) -> Int -> Html a -> Html a
|
||||
viewLiveMetric label formatter value graph =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text label ]
|
||||
, div [ class "value" ] [ text (formatter value) ]
|
||||
, div [ class "graph" ]
|
||||
[ graph
|
||||
, text "(10min)"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
graphNull : Html a
|
||||
graphNull =
|
||||
div [] []
|
||||
|
||||
|
||||
graphSize : Spark.Size
|
||||
graphSize =
|
||||
{ width = 180
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
module Route exposing (Route(..), fromUrl, href, pushUrl, replaceUrl)
|
||||
module Route exposing (Route(..), Router, newRouter)
|
||||
|
||||
import Browser.Navigation as Navigation exposing (Key)
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as Attr
|
||||
import Url exposing (Url)
|
||||
import Url.Builder as Builder
|
||||
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
|
||||
@@ -17,6 +14,25 @@ type Route
|
||||
| Status
|
||||
|
||||
|
||||
type alias Router =
|
||||
{ fromUrl : Url -> Route
|
||||
, toPath : Route -> String
|
||||
}
|
||||
|
||||
|
||||
{-| Returns a configured Router.
|
||||
-}
|
||||
newRouter : String -> Router
|
||||
newRouter basePath =
|
||||
let
|
||||
newPath =
|
||||
prepareBasePath basePath
|
||||
in
|
||||
{ fromUrl = fromUrl newPath
|
||||
, toPath = toPath newPath
|
||||
}
|
||||
|
||||
|
||||
{-| Routes our application handles.
|
||||
-}
|
||||
routes : List (Parser (Route -> a) a)
|
||||
@@ -29,10 +45,26 @@ routes =
|
||||
]
|
||||
|
||||
|
||||
{-| Returns the Route for a given URL.
|
||||
-}
|
||||
fromUrl : String -> Url -> Route
|
||||
fromUrl basePath url =
|
||||
let
|
||||
relative =
|
||||
{ url | path = String.replace basePath "" url.path }
|
||||
in
|
||||
case Parser.parse (oneOf routes) relative of
|
||||
Nothing ->
|
||||
Unknown url.path
|
||||
|
||||
Just route ->
|
||||
route
|
||||
|
||||
|
||||
{-| Convert route to a URI.
|
||||
-}
|
||||
routeToPath : Route -> String
|
||||
routeToPath page =
|
||||
toPath : String -> Route -> String
|
||||
toPath basePath page =
|
||||
let
|
||||
pieces =
|
||||
case page of
|
||||
@@ -54,35 +86,32 @@ routeToPath page =
|
||||
Status ->
|
||||
[ "status" ]
|
||||
in
|
||||
Builder.absolute pieces []
|
||||
basePath ++ Builder.absolute pieces []
|
||||
|
||||
|
||||
{-| Make sure basePath starts with a slash and does not have trailing slashes.
|
||||
|
||||
-- PUBLIC HELPERS
|
||||
"inbucket/" becomes "/inbucket", "" remains ""
|
||||
|
||||
|
||||
href : Route -> Attribute msg
|
||||
href route =
|
||||
Attr.href (routeToPath route)
|
||||
|
||||
|
||||
replaceUrl : Key -> Route -> Cmd msg
|
||||
replaceUrl key =
|
||||
routeToPath >> Navigation.replaceUrl key
|
||||
|
||||
|
||||
pushUrl : Key -> Route -> Cmd msg
|
||||
pushUrl key =
|
||||
routeToPath >> Navigation.pushUrl key
|
||||
|
||||
|
||||
{-| Returns the Route for a given URL.
|
||||
-}
|
||||
fromUrl : Url -> Route
|
||||
fromUrl location =
|
||||
case Parser.parse (oneOf routes) location of
|
||||
Nothing ->
|
||||
Unknown location.path
|
||||
prepareBasePath : String -> String
|
||||
prepareBasePath path =
|
||||
let
|
||||
stripSlashes str =
|
||||
if String.startsWith "/" str then
|
||||
stripSlashes (String.dropLeft 1 str)
|
||||
|
||||
Just route ->
|
||||
route
|
||||
else if String.endsWith "/" str then
|
||||
stripSlashes (String.dropRight 1 str)
|
||||
|
||||
else
|
||||
str
|
||||
|
||||
newPath =
|
||||
stripSlashes path
|
||||
in
|
||||
if newPath == "" then
|
||||
""
|
||||
|
||||
else
|
||||
"/" ++ newPath
|
||||
|
||||
58
ui/src/Timer.elm
Normal file
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:
|
||||
"ctrl mesg" auto
|
||||
"list mesg" 1fr / minmax(200px, 300px) minmax(650px, 1000px);
|
||||
"list mesg" 1fr
|
||||
/ minmax(200px, 300px) minmax(650px, auto);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,23 +116,29 @@ a.button {
|
||||
justify-content: center;
|
||||
grid-gap: 20px;
|
||||
grid-template:
|
||||
"lpad head rpad" auto
|
||||
"head head head" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / minmax(20px, auto) 1fr minmax(20px, auto);
|
||||
height: 100vh;
|
||||
"foot foot foot" auto / 1px 1fr 1px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
.desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1000px) {
|
||||
.app {
|
||||
grid-column-gap: 40px;
|
||||
grid-template:
|
||||
"head head head" auto
|
||||
"lpad head rpad" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / 1px 1fr 1px;
|
||||
height: auto;
|
||||
"foot foot foot" auto
|
||||
/ 1fr minmax(auto, 1300px) 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
display: none;
|
||||
td.desktop, th.desktop {
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +223,10 @@ h3 {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.modal:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/** BUTTONS */
|
||||
|
||||
.button-bar {
|
||||
|
||||
@@ -3,22 +3,55 @@
|
||||
customElements.define(
|
||||
'monitor-messages',
|
||||
class MonitorMessages extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return [ 'src' ]
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const self = super()
|
||||
// TODO make URI/URL configurable.
|
||||
var uri = '/api/v1/monitor/messages'
|
||||
self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri
|
||||
self._socket = null
|
||||
super()
|
||||
this._url = null // Current websocket URL.
|
||||
this._socket = null // Currently open WebSocket.
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.hasAttribute('src')) {
|
||||
this.wsOpen(this.getAttribute('src'))
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// Checking _socket prevents connection attempts prior to connectedCallback().
|
||||
if (this._socket && this.hasAttribute('src')) {
|
||||
this.wsOpen(this.getAttribute('src'))
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.wsClose()
|
||||
}
|
||||
|
||||
// Connects to WebSocket and registers event listeners.
|
||||
wsOpen(uri) {
|
||||
const url =
|
||||
((window.location.protocol === 'https:') ? 'wss://' : 'ws://') +
|
||||
window.location.host + uri
|
||||
if (this._socket && url === this._url) {
|
||||
// Already connected to same URL.
|
||||
return
|
||||
}
|
||||
this.wsClose()
|
||||
this._url = url
|
||||
|
||||
console.info("Connecting to WebSocket", url)
|
||||
const ws = new WebSocket(url)
|
||||
this._socket = ws
|
||||
|
||||
// Register event listeners.
|
||||
const self = this
|
||||
self._socket = new WebSocket(self._url)
|
||||
var ws = self._socket
|
||||
ws.addEventListener('open', function (e) {
|
||||
ws.addEventListener('open', function (_e) {
|
||||
self.dispatchEvent(new CustomEvent('connected', { detail: true }))
|
||||
})
|
||||
ws.addEventListener('close', function (e) {
|
||||
ws.addEventListener('close', function (_e) {
|
||||
self.dispatchEvent(new CustomEvent('connected', { detail: false }))
|
||||
})
|
||||
ws.addEventListener('message', function (e) {
|
||||
@@ -28,11 +61,20 @@ customElements.define(
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
var ws = this._socket
|
||||
// Closes WebSocket connection.
|
||||
wsClose() {
|
||||
const ws = this._socket
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.getAttribute('src')
|
||||
}
|
||||
|
||||
set src(value) {
|
||||
this.setAttribute('src', value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.navbar-dropdown {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-dropdown-button {
|
||||
display: none;
|
||||
}
|
||||
@@ -71,6 +77,9 @@ li.navbar-active span,
|
||||
.navbar-dropdown-content a {
|
||||
color: var(--navbar-color) !important;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a:hover {
|
||||
@@ -139,6 +148,7 @@ li.navbar-active span,
|
||||
|
||||
.navbar-dropdown {
|
||||
padding: 15px 19px 15px 25px;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-button {
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = (env, argv) => {
|
||||
const config = {
|
||||
output: {
|
||||
filename: 'static/[name].[hash:8].js',
|
||||
publicPath: '/',
|
||||
publicPath: '',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -54,11 +54,18 @@ module.exports = (env, argv) => {
|
||||
template: 'public/index.html',
|
||||
favicon: 'public/favicon.png',
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index-dev.html',
|
||||
template: 'public/index-dev.html',
|
||||
favicon: 'public/favicon.png',
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
historyApiFallback: {
|
||||
index: '/index-dev.html',
|
||||
},
|
||||
index: 'index-dev.html',
|
||||
inline: true,
|
||||
historyApiFallback: true,
|
||||
stats: { colors: true },
|
||||
overlay: true,
|
||||
open: true,
|
||||
proxy: [{
|
||||
@@ -66,6 +73,7 @@ module.exports = (env, argv) => {
|
||||
target: 'http://localhost:9000',
|
||||
ws: true,
|
||||
}],
|
||||
stats: { colors: true },
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user