mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-09-15 08:08:54 +00:00
Merge branch 'release/3.0.0-beta1'
This commit is contained in:
+15
-1
@@ -25,7 +25,10 @@ _testmain.go
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# our binaries
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
|
||||
# Inbucket binaries
|
||||
/client
|
||||
/client.exe
|
||||
/inbucket
|
||||
@@ -35,3 +38,14 @@ _testmain.go
|
||||
/cmd/client/client.exe
|
||||
/cmd/inbucket/inbucket
|
||||
/cmd/inbucket/inbucket.exe
|
||||
|
||||
# Elm UI
|
||||
# elm-package generated files
|
||||
/ui/elm-stuff
|
||||
/ui/tests/elm-stuff
|
||||
# elm-repl generated files
|
||||
repl-temp-*
|
||||
# Distribution
|
||||
/ui/dist/
|
||||
# Dependency directories
|
||||
/ui/node_modules
|
||||
|
||||
+12
-3
@@ -2,7 +2,7 @@ project_name: inbucket
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: jhillyerd
|
||||
owner: inbucket
|
||||
name: inbucket
|
||||
name_template: '{{.Tag}}'
|
||||
|
||||
@@ -12,8 +12,14 @@ brew:
|
||||
email: goreleaser@carlosbecker.com
|
||||
install: bin.install ""
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
- binary: inbucket
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
@@ -26,6 +32,8 @@ builds:
|
||||
main: ./cmd/inbucket
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
- binary: inbucket-client
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
@@ -51,7 +59,8 @@ archive:
|
||||
- README*
|
||||
- CHANGELOG*
|
||||
- etc/**/*
|
||||
- ui/**/*
|
||||
- ui/dist/**/*
|
||||
- ui/greeting.html
|
||||
|
||||
nfpm:
|
||||
vendor: inbucket.org
|
||||
@@ -63,7 +72,7 @@ nfpm:
|
||||
- deb
|
||||
- rpm
|
||||
files:
|
||||
"ui/**/*": "/usr/local/share/inbucket/ui"
|
||||
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
||||
config_files:
|
||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||
|
||||
+35
-18
@@ -1,26 +1,43 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- rpm
|
||||
|
||||
env:
|
||||
global:
|
||||
- GO111MODULE=on
|
||||
- DEPLOY_WITH_MAJOR="1.11"
|
||||
|
||||
before_script:
|
||||
- go get golang.org/x/lint/golint
|
||||
- make deps
|
||||
language: go
|
||||
|
||||
go:
|
||||
- "1.10.x"
|
||||
- "1.11.x"
|
||||
install:
|
||||
- "go get golang.org/x/lint/golint"
|
||||
- "make deps"
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
jobs:
|
||||
include:
|
||||
- go: "1.11.x"
|
||||
- go: "master"
|
||||
- language: elm
|
||||
elm: "0.19.0"
|
||||
install:
|
||||
- "cd ui"
|
||||
- "npm ci"
|
||||
script:
|
||||
- "elm-format --validate ."
|
||||
- "npm run build"
|
||||
- stage: deploy
|
||||
go: "1.11.x"
|
||||
before_install:
|
||||
- "nvm install 10.13.0"
|
||||
install:
|
||||
- "cd ui"
|
||||
- "npm ci"
|
||||
- "npm run build"
|
||||
- "cd .."
|
||||
script: "curl -sL https://git.io/goreleaser | bash"
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- rpm
|
||||
|
||||
stages:
|
||||
- test
|
||||
- name: deploy
|
||||
if: tag IS present
|
||||
|
||||
+31
-17
@@ -4,6 +4,19 @@ 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-beta1]
|
||||
|
||||
### Added
|
||||
- `posix-millis` field to REST message and header responses for easier date
|
||||
parsing.
|
||||
|
||||
### Changed
|
||||
- Rewrote the user interface from scratch, it's now an Elm powered single page
|
||||
application.
|
||||
- Moved the Inbucket repository to its own GitHub organization.
|
||||
- Update to enmime v0.5.0
|
||||
|
||||
|
||||
## v2.1.0
|
||||
|
||||
No change from beta1.
|
||||
@@ -102,7 +115,7 @@ No change from beta1.
|
||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||
provides a more natural API
|
||||
- Powerful command line REST
|
||||
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
|
||||
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
|
||||
- Allow use of `latest` as a message ID in REST calls
|
||||
|
||||
### Changed
|
||||
@@ -117,9 +130,9 @@ No change from beta1.
|
||||
### Added
|
||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||
- Attachment list to [GET message
|
||||
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
|
||||
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
|
||||
- [Go client for REST
|
||||
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
|
||||
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
|
||||
- Monitor feature: lists messages as they arrive, regardless of their
|
||||
destination mailbox
|
||||
- Make `@inbucket` mailbox prompt configurable
|
||||
@@ -182,19 +195,20 @@ No change from beta1.
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
|
||||
## Release Checklist
|
||||
@@ -207,7 +221,7 @@ No change from beta1.
|
||||
3. Run tests
|
||||
4. Test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release: `git flow release finish`
|
||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
||||
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
|
||||
complete
|
||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
|
||||
|
||||
+7
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Build
|
||||
FROM golang:1.11-alpine3.8 as builder
|
||||
RUN apk add --no-cache --virtual .build-deps git make
|
||||
RUN apk add --no-cache --virtual .build-deps git make npm
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
@@ -10,14 +10,18 @@ RUN make clean deps
|
||||
RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
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.8
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=builder /build/inbucket bin
|
||||
COPY --from=builder /build/ui/dist ui
|
||||
COPY etc/docker/defaults/greeting.html defaults
|
||||
COPY ui ui
|
||||
COPY etc/docker/defaults/start-inbucket.sh /
|
||||
|
||||
# Configuration
|
||||
@@ -26,6 +30,7 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||
ENV INBUCKET_WEB_UIDIR=ui
|
||||
ENV INBUCKET_STORAGE_TYPE file
|
||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Inbucket
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||
[][Build Status]
|
||||
|
||||
Inbucket is an email testing service; it will accept messages for any email
|
||||
address and make them available via web, REST and POP3. Once compiled,
|
||||
@@ -8,7 +8,7 @@ Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
||||
are all built in).
|
||||
|
||||
A Go client for the REST API is available in
|
||||
`github.com/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
|
||||
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
||||
|
||||
Read more at the [Inbucket Website]
|
||||
|
||||
@@ -25,7 +25,7 @@ to contribute code to the project check out [CONTRIBUTING.md].
|
||||
## Homebrew Tap
|
||||
|
||||
(currently broken, being tracked in [issue
|
||||
#68](https://github.com/jhillyerd/inbucket/issues/68))
|
||||
#68](https://github.com/inbucket/inbucket/issues/68))
|
||||
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
@@ -37,12 +37,12 @@ You will need a functioning [Go installation][Google Go] for this to work.
|
||||
|
||||
Grab the Inbucket source code and compile the daemon:
|
||||
|
||||
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
|
||||
go get -v github.com/inbucket/inbucket/cmd/inbucket
|
||||
|
||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||
Unix and OS X machines as is. Launch the daemon:
|
||||
|
||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/inbucket/inbucket/etc/inbucket.conf
|
||||
|
||||
By default the SMTP server will be listening on localhost port 2500 and
|
||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
@@ -56,15 +56,15 @@ The Inbucket website has a more complete guide to
|
||||
Inbucket is written in [Google Go]
|
||||
|
||||
Inbucket is open source software released under the MIT License. The latest
|
||||
version can be found at https://github.com/jhillyerd/inbucket
|
||||
version can be found at https://github.com/inbucket/inbucket
|
||||
|
||||
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
|
||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[Go API docs]: https://godoc.org/github.com/inbucket/inbucket/pkg/rest/client
|
||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||
[Google Go]: http://golang.org/
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
||||
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||
[Inbucket Website]: http://www.inbucket.org/
|
||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
||||
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type matchCmd struct {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
type mboxCmd struct {
|
||||
|
||||
+16
-18
@@ -14,18 +14,18 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/pop3"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/file"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"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/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -40,10 +40,8 @@ var (
|
||||
|
||||
func init() {
|
||||
// Server uptime for status page.
|
||||
startTime := time.Now()
|
||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
||||
return time.Since(startTime) / time.Second
|
||||
}))
|
||||
startTime := expvar.NewInt("startMillis")
|
||||
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||
|
||||
// Goroutine count for status page.
|
||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||
@@ -124,9 +122,9 @@ func main() {
|
||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||
retentionScanner.Start()
|
||||
// Start HTTP server.
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
|
||||
+2
-38
@@ -28,11 +28,8 @@ 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_UIDIR ui User interface dir
|
||||
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
|
||||
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
|
||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
||||
@@ -298,7 +295,7 @@ doesn't contain the `ui` directory at startup.
|
||||
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||
static assets from the `static` sub-directory.
|
||||
|
||||
- Default: `ui`
|
||||
- Default: `ui/dist`
|
||||
- Values: Operating system specific path syntax
|
||||
|
||||
### Greeting HTML File
|
||||
@@ -311,39 +308,6 @@ Inbucket installation, as well as link to REST documentation, etc.
|
||||
|
||||
- Default: `ui/greeting.html`
|
||||
|
||||
### Template Caching
|
||||
|
||||
`INBUCKET_WEB_TEMPLATECACHE`
|
||||
|
||||
Tells Inbucket to cache parsed template files. This should be left as default
|
||||
unless you are a developer working on the Inbucket web interface.
|
||||
|
||||
- Default: `true`
|
||||
- Values: `true` or `false`
|
||||
|
||||
### Mailbox Prompt
|
||||
|
||||
`INBUCKET_WEB_MAILBOXPROMPT`
|
||||
|
||||
Text prompt displayed to the right of the mailbox name input field in the web
|
||||
interface. Can be used to nudge your users into typing just the mailbox name
|
||||
instead of an entire email address.
|
||||
|
||||
Set to an empty string to hide the prompt.
|
||||
|
||||
- Default: `@inbucket`
|
||||
|
||||
### Cookie Authentication Key
|
||||
|
||||
`INBUCKET_WEB_COOKIEAUTHKEY`
|
||||
|
||||
Inbucket stores session information in an encrypted browser cookie. Unless
|
||||
specified, Inbucket generates a random key at startup. The only notable data
|
||||
stored in a user session is the list of recently accessed mailboxes.
|
||||
|
||||
- Default: None
|
||||
- Value: Text string, no particular format required
|
||||
|
||||
### Monitor Visible
|
||||
|
||||
`INBUCKET_WEB_MONITORVISIBLE`
|
||||
|
||||
+17
-2
@@ -3,16 +3,31 @@
|
||||
# description: Developer friendly Inbucket configuration
|
||||
|
||||
export INBUCKET_LOGLEVEL="debug"
|
||||
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||
#export INBUCKET_SMTP_DEFAULTSTORE="false"
|
||||
export INBUCKET_SMTP_STOREDOMAINS="important.local"
|
||||
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||
export INBUCKET_STORAGE_TYPE="file"
|
||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
|
||||
|
||||
if ! test -x ./inbucket; then
|
||||
echo "$PWD/inbucket not found/executable!" >&2
|
||||
echo "Run this script from the inbucket root directory after running make" >&2
|
||||
echo "Run this script from the inbucket root directory after running make." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||
if ! test -f "$index"; then
|
||||
echo "$index does not exist!" >&2
|
||||
echo "Run 'npm run build' from the 'ui' directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: MIME Errors
|
||||
Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
|
||||
Content-Type: multipart/alternative; boundary="Enmime-Test-100"
|
||||
|
||||
--Enmime-Test-100
|
||||
Content-Transfer-Encoding: 8bit
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
|
||||
Using Unicode/UTF-8, you can write in emails and source code things such as
|
||||
|
||||
Mathematics and sciences:
|
||||
|
||||
∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ⎧⎡⎛┌─────┐⎞⎤⎫
|
||||
⎪⎢⎜│a²+b³ ⎟⎥⎪
|
||||
∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β), ⎪⎢⎜│───── ⎟⎥⎪
|
||||
⎪⎢⎜⎷ c₈ ⎟⎥⎪
|
||||
ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ, ⎨⎢⎜ ⎟⎥⎬
|
||||
⎪⎢⎜ ∞ ⎟⎥⎪
|
||||
⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫), ⎪⎢⎜ ⎲ ⎟⎥⎪
|
||||
⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
|
||||
2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm ⎩⎣⎝i=1 ⎠⎦⎭
|
||||
|
||||
Linguistics and dictionaries:
|
||||
|
||||
ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
|
||||
Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
|
||||
|
||||
--Enmime-Test-100
|
||||
@@ -56,3 +56,6 @@ swaks $* --data outlook.raw
|
||||
# Non-mime responsive HTML test
|
||||
swaks $* --data nonmime-html-responsive.raw
|
||||
swaks $* --data nonmime-html-inlined.raw
|
||||
|
||||
# Incorrect charset, malformed final boundary
|
||||
swaks $* --data mime-errors.raw
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# travis-deploy.sh
|
||||
# description: Trigger goreleaser deployment in correct build scenarios
|
||||
|
||||
set -eo pipefail
|
||||
set -x
|
||||
|
||||
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
|
||||
curl -sL https://git.io/goreleaser | bash
|
||||
fi
|
||||
@@ -1,14 +1,13 @@
|
||||
module github.com/jhillyerd/inbucket
|
||||
module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.0
|
||||
github.com/gorilla/mux v1.6.2
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.1.3
|
||||
github.com/gorilla/websocket v1.4.0
|
||||
github.com/jhillyerd/enmime v0.2.1
|
||||
github.com/jhillyerd/enmime v0.5.0
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.1
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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/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 v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
|
||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
@@ -8,16 +12,12 @@ 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.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
|
||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
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/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
|
||||
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
|
||||
github.com/jhillyerd/enmime v0.5.0 h1:0U3mdCFsFsdQcuztie8g+eQgZ2GhtVCun8AJArOqdzc=
|
||||
github.com/jhillyerd/enmime v0.5.0/go.mod h1:/bb6lwXIWgsVnrO4uuLg8rBVqidJYeG9I34d/WfWurg=
|
||||
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.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
||||
@@ -28,10 +28,14 @@ github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVm
|
||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||
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/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/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/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
|
||||
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
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/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
@@ -93,11 +93,8 @@ 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"`
|
||||
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
|
||||
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
|
||||
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
|
||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
|
||||
broadcast := msghub.Message{
|
||||
Mailbox: to.Mailbox,
|
||||
ID: id,
|
||||
From: delivery.From().String(),
|
||||
From: stringutil.StringAddress(delivery.From()),
|
||||
To: stringutil.StringAddressList(delivery.To()),
|
||||
Subject: delivery.Subject(),
|
||||
Date: delivery.Date(),
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Metadata holds information about a message, but not the content.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
// Addressing handles email address policy.
|
||||
@@ -159,16 +159,16 @@ func ValidateDomainPart(domain string) bool {
|
||||
// domain part is optional and not validated.
|
||||
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||
if address == "" {
|
||||
return "", "", fmt.Errorf("Empty address")
|
||||
return "", "", fmt.Errorf("empty address")
|
||||
}
|
||||
if len(address) > 320 {
|
||||
return "", "", fmt.Errorf("Address exceeds 320 characters")
|
||||
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||
}
|
||||
if address[0] == '@' {
|
||||
return "", "", fmt.Errorf("Address cannot start with @ symbol")
|
||||
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
||||
}
|
||||
if address[0] == '.' {
|
||||
return "", "", fmt.Errorf("Address cannot start with a period")
|
||||
return "", "", fmt.Errorf("address cannot start with a period")
|
||||
}
|
||||
// Loop over address parsing out local part.
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
)
|
||||
|
||||
func TestShouldAcceptDomain(t *testing.T) {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
// MailboxListV1 renders a list of messages in a mailbox
|
||||
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||
for i, msg := range messages {
|
||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
}
|
||||
}
|
||||
return web.RenderJSON(w, jmessages)
|
||||
@@ -77,15 +78,16 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
}
|
||||
return web.RenderJSON(w,
|
||||
&model.JSONMessageV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Body: &model.JSONMessageBodyV1{
|
||||
Text: msg.Text(),
|
||||
HTML: msg.HTML(),
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||
@@ -120,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
|
||||
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||
|
||||
@@ -221,6 +223,7 @@ func TestRestMessage(t *testing.T) {
|
||||
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||
decodedStringEquals(t, result, "subject", "subject 1")
|
||||
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||
decodedNumberEquals(t, result, "size", 0)
|
||||
decodedBoolEquals(t, result, "seen", true)
|
||||
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
)
|
||||
|
||||
// Client accesses the Inbucket REST API v1
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
func TestClientV1ListMailbox(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
)
|
||||
|
||||
// Example demonstrates basic usage for the Inbucket REST client.
|
||||
|
||||
@@ -33,7 +33,7 @@ func (c *restClient) do(method, uri string, body []byte) (*http.Response, error)
|
||||
}
|
||||
req, err := http.NewRequest(method, url.String(), r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||
}
|
||||
return c.client.Do(req)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
||||
@@ -77,5 +77,5 @@ func (c *restClient) doJSONBody(method string, uri string, body []byte, v interf
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||
type JSONMessageHeaderV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
|
||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Body *JSONMessageBodyV1 `json:"body"`
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package rest
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
import "github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||
|
||||
// SetupRoutes populates the routes for the REST interface
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
return
|
||||
}
|
||||
header := &model.JSONMessageHeaderV1{
|
||||
Mailbox: msg.Mailbox,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Mailbox: msg.Mailbox,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
}
|
||||
if conn.WriteJSON(header) != nil {
|
||||
// Write failed
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
},
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
|
||||
return buf
|
||||
}
|
||||
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
|
||||
t.Errorf("JSON result%s", msg)
|
||||
return
|
||||
}
|
||||
if got, ok := val.(float64); ok {
|
||||
got, ok := val.(float64)
|
||||
if ok {
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
|
||||
path, val, val, int64(got), want, int64(want))
|
||||
}
|
||||
|
||||
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package web
|
||||
|
||||
type jsonAppConfig struct {
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
}
|
||||
@@ -5,17 +5,15 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
)
|
||||
|
||||
// Context is passed into every request handler function
|
||||
// TODO remove redundant web config
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
Session *sessions.Session
|
||||
MsgHub *msghub.Hub
|
||||
Manager message.Manager
|
||||
RootConfig *config.Root
|
||||
@@ -48,24 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
|
||||
// NewContext returns a Context for the given HTTP Request
|
||||
func NewContext(req *http.Request) (*Context, error) {
|
||||
vars := mux.Vars(req)
|
||||
sess, err := sessionStore.Get(req, "inbucket")
|
||||
if err != nil {
|
||||
if sess == nil {
|
||||
// No session, must fail
|
||||
return nil, err
|
||||
}
|
||||
// The session cookie was probably signed by an old key, ignore it
|
||||
// gorilla created an empty session for us
|
||||
err = nil
|
||||
}
|
||||
ctx := &Context{
|
||||
Vars: vars,
|
||||
Session: sess,
|
||||
MsgHub: msgHub,
|
||||
Manager: manager,
|
||||
RootConfig: rootConfig,
|
||||
WebConfig: rootConfig.Web,
|
||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
return ctx, err
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket.
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler.
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context.
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it.
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error handling request")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cookieHandler injects an HTTP cookie into the response.
|
||||
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
|
||||
http.SetCookie(w, cookie)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// fileHandler creates a handler that sends the named file regardless of the requested URL.
|
||||
func fileHandler(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||
Msg("Error opening file")
|
||||
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
d, err := f.Stat()
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||
Msg("Error stating file")
|
||||
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
|
||||
})
|
||||
}
|
||||
|
||||
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
|
||||
// returning specified statusCode to the client.
|
||||
func noMatchHandler(statusCode int, message string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
|
||||
w.WriteHeader(statusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// requestLoggingWrapper returns middleware that logs client requests.
|
||||
func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
@@ -3,62 +3,20 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"address": stringutil.StringAddress,
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"stringsJoin": strings.Join,
|
||||
"textToHtml": TextToHTML,
|
||||
}
|
||||
|
||||
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||
|
||||
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
|
||||
// otherwise Mon Jan 2, 2006
|
||||
func FriendlyTime(t time.Time) template.HTML {
|
||||
ty, tm, td := t.Date()
|
||||
ny, nm, nd := time.Now().Date()
|
||||
if (ty == ny) && (tm == nm) && (td == nd) {
|
||||
return template.HTML(t.Format("03:04:05 PM"))
|
||||
}
|
||||
return template.HTML(t.Format("Mon Jan 2, 2006"))
|
||||
}
|
||||
|
||||
// Reverse routing function (shared with templates)
|
||||
func Reverse(name string, things ...interface{}) string {
|
||||
// Convert the things to strings
|
||||
strs := make([]string, len(things))
|
||||
for i, th := range things {
|
||||
strs[i] = fmt.Sprint(th)
|
||||
}
|
||||
// Grab the route
|
||||
u, err := Router.Get(name).URL(strs...)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("name", name).Err(err).
|
||||
Msg("Failed to reverse route")
|
||||
return "/ROUTE-ERROR"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func TextToHTML(text string) template.HTML {
|
||||
func TextToHTML(text string) string {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(replacer.Replace(text))
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextToHtml(t *testing.T) {
|
||||
// Identity
|
||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
||||
|
||||
// Check it escapes
|
||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
||||
|
||||
// Check for linebreaks
|
||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
}
|
||||
|
||||
func TestURLDetection(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://google.com/"),
|
||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://a.com/?q=a&n=v"),
|
||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
||||
testCases := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{
|
||||
input: "html",
|
||||
want: "html",
|
||||
},
|
||||
// Check it escapes.
|
||||
{
|
||||
input: "<html>",
|
||||
want: "<html>",
|
||||
},
|
||||
// Check for linebreaks.
|
||||
{
|
||||
input: "line\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\r\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\rbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
// Check URL detection.
|
||||
{
|
||||
input: "http://google.com/",
|
||||
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||
},
|
||||
{
|
||||
input: "http://a.com/?q=a&n=v",
|
||||
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||
},
|
||||
{
|
||||
input: "(http://a.com/?q=a&n=v)",
|
||||
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := TextToHTML(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+44
-54
@@ -3,30 +3,22 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
const (
|
||||
staticDir = "static"
|
||||
templateDir = "templates"
|
||||
)
|
||||
|
||||
var (
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
@@ -39,7 +31,6 @@ var (
|
||||
rootConfig *config.Root
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
globalShutdown chan bool
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
@@ -51,7 +42,7 @@ func init() {
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method
|
||||
// Initialize sets up things for unit tests or the Start() method.
|
||||
func Initialize(
|
||||
conf *config.Root,
|
||||
shutdownChan chan bool,
|
||||
@@ -61,16 +52,13 @@ func Initialize(
|
||||
rootConfig = conf
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
// NewContext() will use this DataStore for the web handlers.
|
||||
msgHub = mh
|
||||
manager = mm
|
||||
|
||||
// Content Paths
|
||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
||||
// Dynamic paths.
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(staticPath))))
|
||||
Router.Handle("/debug/vars", expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
@@ -82,23 +70,32 @@ func Initialize(
|
||||
Msg("Go pprof tools installed to /debug/pprof")
|
||||
}
|
||||
|
||||
// Session cookie setup
|
||||
if conf.Web.CookieAuthKey == "" {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Info().Str("module", "web").Str("phase", "startup").
|
||||
Msg("Using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
||||
}
|
||||
// Static paths.
|
||||
Router.PathPrefix("/static").Handler(
|
||||
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||
Router.Path("/favicon.png").Handler(
|
||||
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||
|
||||
// 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)
|
||||
|
||||
// Error handlers.
|
||||
Router.NotFoundHandler = noMatchHandler(
|
||||
http.StatusNotFound, "No route matches URI path")
|
||||
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
server = &http.Server{
|
||||
Addr: rootConfig.Web.Addr,
|
||||
Handler: Router,
|
||||
Handler: requestLoggingWrapper(Router),
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
@@ -132,6 +129,22 @@ func Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||
o := &jsonAppConfig{
|
||||
MonitorVisible: webConfig.MonitorVisible,
|
||||
}
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||
Msg("Failed to convert app-config to JSON")
|
||||
}
|
||||
return &http.Cookie{
|
||||
Name: "app-config",
|
||||
Value: url.PathEscape(string(b)),
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
@@ -148,29 +161,6 @@ func serve(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||
Msg("Error handling request")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var cachedMutex sync.Mutex
|
||||
var cachedTemplates = map[string]*template.Template{}
|
||||
var cachedPartials = map[string]*template.Template{}
|
||||
|
||||
// RenderTemplate fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, false)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||
Msg("Error in template")
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// RenderPartial fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, true)
|
||||
if err != nil {
|
||||
log.Error().Str("module", "web").Str("path", name).Err(err).
|
||||
Msg("Error in template")
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// ParseTemplate loads the requested template along with _base.html, caching
|
||||
// the result (if configured to do so)
|
||||
func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
||||
cachedMutex.Lock()
|
||||
defer cachedMutex.Unlock()
|
||||
|
||||
if t, ok := cachedTemplates[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
|
||||
|
||||
var err error
|
||||
var t *template.Template
|
||||
if partial {
|
||||
// Need to get basename of file to make it root template w/ funcs
|
||||
base := path.Base(name)
|
||||
t = template.New(base).Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(tempFile)
|
||||
} else {
|
||||
t = template.New("_base.html").Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(
|
||||
filepath.Join(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Allows us to disable caching for theme development
|
||||
if rootConfig.Web.TemplateCache {
|
||||
if partial {
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
|
||||
cachedTemplates[name] = t
|
||||
} else {
|
||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
|
||||
cachedTemplates[name] = t
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
func TestHashLock(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Message is a memory store message.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Store implements an in-memory message store.
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
// TestSuite runs storage package test suite on file store.
|
||||
|
||||
@@ -3,17 +3,15 @@ package storage
|
||||
import (
|
||||
"container/list"
|
||||
"expvar"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/metric"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
retentionScanCompleted = time.Now()
|
||||
retentionScanCompletedMu sync.RWMutex
|
||||
scanCompletedMillis = new(expvar.Int)
|
||||
|
||||
// History counters
|
||||
expRetentionDeletesTotal = new(expvar.Int)
|
||||
@@ -34,7 +32,7 @@ var (
|
||||
|
||||
func init() {
|
||||
rm := expvar.NewMap("retention")
|
||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
||||
rm.Set("ScanCompletedMillis", scanCompletedMillis)
|
||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||
rm.Set("Period", expRetentionPeriod)
|
||||
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
|
||||
return err
|
||||
}
|
||||
// Update metrics
|
||||
setRetentionScanCompleted(time.Now())
|
||||
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
expRetainedSize.Set(storeSize)
|
||||
return nil
|
||||
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
|
||||
<-rs.retentionShutdown
|
||||
}
|
||||
}
|
||||
|
||||
func setRetentionScanCompleted(t time.Time) {
|
||||
retentionScanCompletedMu.Lock()
|
||||
defer retentionScanCompletedMu.Unlock()
|
||||
retentionScanCompleted = t
|
||||
}
|
||||
|
||||
func getRetentionScanCompleted() time.Time {
|
||||
retentionScanCompletedMu.RLock()
|
||||
defer retentionScanCompletedMu.RUnlock()
|
||||
return retentionScanCompleted
|
||||
}
|
||||
|
||||
func secondsSinceRetentionScanCompleted() interface{} {
|
||||
return time.Since(getRetentionScanCompleted()) / time.Second
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/test"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
)
|
||||
|
||||
func TestDoRetentionScan(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
)
|
||||
|
||||
func TestHashMailboxName(t *testing.T) {
|
||||
|
||||
@@ -11,18 +11,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/msghub"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/rest"
|
||||
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"github.com/inbucket/inbucket/pkg/webui"
|
||||
"github.com/jhillyerd/goldiff"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,8 +37,7 @@ func TestSuite(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = stopServer
|
||||
// defer stopServer()
|
||||
defer stopServer()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -152,6 +153,7 @@ func formatMessage(m *client.Message) []byte {
|
||||
|
||||
func startServer() (func(), error) {
|
||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||
storage.Constructors["memory"] = mem.New
|
||||
os.Clearenv()
|
||||
conf, err := config.Process()
|
||||
@@ -169,9 +171,9 @@ func startServer() (func(), error) {
|
||||
addrPolicy := &policy.Addressing{Config: conf}
|
||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||
// Start HTTP server.
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start SMTP server.
|
||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||
|
||||
+4
-4
@@ -3,10 +3,10 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// ManagerStub is a test stub for message.Manager
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package test
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// StoreStub stubs storage.Store for testing.
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/message"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// StoreFactory returns a new store for the test suite.
|
||||
|
||||
+58
-175
@@ -1,144 +1,82 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// MailboxIndex renders the index page for a particular mailbox
|
||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Form values must be validated manually
|
||||
name := req.FormValue("name")
|
||||
selected := req.FormValue("id")
|
||||
if len(name) == 0 {
|
||||
ctx.Session.AddFlash("Account name is required", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
name, err = ctx.Manager.MailboxForAddress(name)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Remember this mailbox was visited
|
||||
RememberMailbox(ctx, name)
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"name": name,
|
||||
"selected": selected,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
|
||||
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxLink handles pretty links to a particular message. Renders a redirect
|
||||
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxList renders a list of messages in a mailbox. Renders a partial
|
||||
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages, err := ctx.Manager.GetMetadata(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"messages": messages,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
|
||||
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
if err != nil && err != storage.ErrNotExist {
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if msg == nil {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
|
||||
attachments := make([]*jsonAttachment, 0)
|
||||
for i, part := range msg.Attachments() {
|
||||
attachments = append(attachments, &jsonAttachment{
|
||||
ID: strconv.Itoa(i),
|
||||
FileName: part.FileName,
|
||||
ContentType: part.ContentType,
|
||||
})
|
||||
}
|
||||
body := template.HTML(web.TextToHTML(msg.Text()))
|
||||
htmlAvailable := msg.HTML() != ""
|
||||
var htmlBody template.HTML
|
||||
if htmlAvailable {
|
||||
|
||||
mimeErrors := make([]*jsonMIMEError, 0)
|
||||
for _, e := range msg.MIMEErrors() {
|
||||
mimeErrors = append(mimeErrors, &jsonMIMEError{
|
||||
Name: e.Name,
|
||||
Detail: e.Detail,
|
||||
Severe: e.Severe,
|
||||
})
|
||||
}
|
||||
|
||||
// Sanitize HTML body.
|
||||
htmlBody := ""
|
||||
if msg.HTML() != "" {
|
||||
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||
htmlBody = template.HTML(str)
|
||||
htmlBody = str
|
||||
} else {
|
||||
// Soft failure, render empty tab.
|
||||
htmlBody = "Inbucket HTML sanitizer failed."
|
||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||
Msg("HTML sanitizer failed")
|
||||
}
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"message": msg,
|
||||
"body": body,
|
||||
"htmlAvailable": htmlAvailable,
|
||||
"htmlBody": htmlBody,
|
||||
"mimeErrors": msg.MIMEErrors(),
|
||||
"attachments": msg.Attachments(),
|
||||
})
|
||||
|
||||
return web.RenderJSON(w,
|
||||
&jsonMessage{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Text: web.TextToHTML(msg.Text()),
|
||||
HTML: htmlBody,
|
||||
Attachments: attachments,
|
||||
Errors: mimeErrors,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxHTML displays the HTML content of a message. Renders a partial
|
||||
@@ -158,14 +96,10 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (er
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
// Render partial template
|
||||
// Render HTML
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"message": msg,
|
||||
"body": template.HTML(msg.HTML()),
|
||||
})
|
||||
_, err = w.Write([]byte(msg.HTML()))
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
|
||||
@@ -191,66 +125,18 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxDownloadAttach sends the attachment to the client; disposition:
|
||||
// attachment, type: application/octet-stream
|
||||
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
numStr := ctx.Vars["num"]
|
||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if int(num) >= len(msg.Attachments()) {
|
||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Output attachment
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
_, err = w.Write(msg.Attachments()[num].Content)
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
id := ctx.Vars["id"]
|
||||
numStr := ctx.Vars["num"]
|
||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
@@ -262,10 +148,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Contex
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if int(num) >= len(msg.Attachments()) {
|
||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
return errors.New("requested attachment number does not exist")
|
||||
}
|
||||
// Output attachment
|
||||
part := msg.Attachments()[num]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package webui
|
||||
|
||||
import "time"
|
||||
|
||||
// jsonMessage formats message data for the UI.
|
||||
type jsonMessage struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
PosixMillis int64 `json:"posix-millis"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Header map[string][]string `json:"header"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
Attachments []*jsonAttachment `json:"attachments"`
|
||||
Errors []*jsonMIMEError `json:"errors"`
|
||||
}
|
||||
|
||||
// jsonAttachment formats attachment data for the UI.
|
||||
type jsonAttachment struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"filename"`
|
||||
ContentType string `json:"content-type"`
|
||||
}
|
||||
|
||||
type jsonMIMEError struct {
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail"`
|
||||
Severe bool `json:"severe"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
const (
|
||||
// maximum mailboxes to remember
|
||||
maxRemembered = 8
|
||||
// session value key; referenced in templates, do not change
|
||||
mailboxKey = "recentMailboxes"
|
||||
)
|
||||
|
||||
// RememberMailbox manages the list of recently accessed mailboxes stored in the session
|
||||
func RememberMailbox(ctx *web.Context, mailbox string) {
|
||||
recent := RecentMailboxes(ctx)
|
||||
newRecent := make([]string, 1, maxRemembered)
|
||||
newRecent[0] = mailbox
|
||||
|
||||
for _, recBox := range recent {
|
||||
// Insert until newRecent is full, but don't repeat the new mailbox
|
||||
if len(newRecent) < maxRemembered && mailbox != recBox {
|
||||
newRecent = append(newRecent, recBox)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Session.Values[mailboxKey] = newRecent
|
||||
}
|
||||
|
||||
// RecentMailboxes returns a slice of the most recently accessed mailboxes
|
||||
func RecentMailboxes(ctx *web.Context) []string {
|
||||
val := ctx.Session.Values[mailboxKey]
|
||||
recent, _ := val.([]string)
|
||||
return recent
|
||||
}
|
||||
@@ -2,98 +2,52 @@ package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
// RootIndex serves the Inbucket landing page
|
||||
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// RootGreeting serves the Inbucket greeting.
|
||||
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to load greeting: %v", err)
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"greeting": template.HTML(string(greeting)),
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, err = w.Write(greeting)
|
||||
return err
|
||||
}
|
||||
|
||||
// RootMonitor serves the Inbucket monitor page
|
||||
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
if !ctx.RootConfig.Web.MonitorVisible {
|
||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
})
|
||||
}
|
||||
|
||||
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
|
||||
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
if !ctx.RootConfig.Web.MonitorVisible {
|
||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// RootStatus serves the Inbucket status page
|
||||
// RootStatus renders portions of the server configuration as JSON.
|
||||
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
root := ctx.RootConfig
|
||||
retPeriod := ""
|
||||
if root.Storage.RetentionPeriod > 0 {
|
||||
retPeriod = root.Storage.RetentionPeriod.String()
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"version": config.Version,
|
||||
"buildDate": config.BuildDate,
|
||||
"smtpListener": ctx.RootConfig.SMTP.Addr,
|
||||
"pop3Listener": ctx.RootConfig.POP3.Addr,
|
||||
"webListener": ctx.RootConfig.Web.Addr,
|
||||
"smtpConfig": ctx.RootConfig.SMTP,
|
||||
"storageConfig": ctx.RootConfig.Storage,
|
||||
})
|
||||
|
||||
return web.RenderJSON(w,
|
||||
&jsonServerConfig{
|
||||
Version: config.Version,
|
||||
BuildDate: config.BuildDate,
|
||||
POP3Listener: root.POP3.Addr,
|
||||
WebListener: root.Web.Addr,
|
||||
SMTPConfig: jsonSMTPConfig{
|
||||
Addr: root.SMTP.Addr,
|
||||
DefaultAccept: root.SMTP.DefaultAccept,
|
||||
AcceptDomains: root.SMTP.AcceptDomains,
|
||||
RejectDomains: root.SMTP.RejectDomains,
|
||||
DefaultStore: root.SMTP.DefaultStore,
|
||||
StoreDomains: root.SMTP.StoreDomains,
|
||||
DiscardDomains: root.SMTP.DiscardDomains,
|
||||
},
|
||||
StorageConfig: jsonStorageConfig{
|
||||
MailboxMsgCap: root.Storage.MailboxMsgCap,
|
||||
StoreType: root.Storage.Type,
|
||||
RetentionPeriod: retPeriod,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+7
-21
@@ -3,35 +3,21 @@ package webui
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/inbucket/inbucket/pkg/server/web"
|
||||
)
|
||||
|
||||
// SetupRoutes populates routes for the webui into the provided Router
|
||||
// SetupRoutes populates routes for the webui into the provided Router.
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
r.Path("/").Handler(
|
||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
||||
r.Path("/monitor").Handler(
|
||||
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
|
||||
r.Path("/monitor/{name}").Handler(
|
||||
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
||||
r.Path("/greeting").Handler(
|
||||
web.Handler(RootGreeting)).Name("RootGreeting").Methods("GET")
|
||||
r.Path("/status").Handler(
|
||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
||||
r.Path("/link/{name}/{id}").Handler(
|
||||
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
|
||||
r.Path("/mailbox").Handler(
|
||||
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
|
||||
r.Path("/mailbox/{name}").Handler(
|
||||
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
|
||||
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}/html").Handler(
|
||||
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
|
||||
web.Handler(MailboxHTML)).Name("MailboxHTML").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}/source").Handler(
|
||||
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
||||
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
|
||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
||||
r.Path("/{name}").Handler(
|
||||
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package sanitize_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||
)
|
||||
|
||||
// TestHTMLPlainStrings test plain text passthrough
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package webui
|
||||
|
||||
type jsonServerConfig struct {
|
||||
Version string `json:"version"`
|
||||
BuildDate string `json:"build-date"`
|
||||
POP3Listener string `json:"pop3-listener"`
|
||||
WebListener string `json:"web-listener"`
|
||||
SMTPConfig jsonSMTPConfig `json:"smtp-config"`
|
||||
StorageConfig jsonStorageConfig `json:"storage-config"`
|
||||
}
|
||||
|
||||
type jsonSMTPConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
DefaultAccept bool `json:"default-accept"`
|
||||
AcceptDomains []string `json:"accept-domains"`
|
||||
RejectDomains []string `json:"reject-domains"`
|
||||
DefaultStore bool `json:"default-store"`
|
||||
StoreDomains []string `json:"store-domains"`
|
||||
DiscardDomains []string `json:"discard-domains"`
|
||||
}
|
||||
|
||||
type jsonStorageConfig struct {
|
||||
MailboxMsgCap int `json:"mailbox-msg-cap"`
|
||||
StoreType string `json:"store-type"`
|
||||
RetentionPeriod string `json:"retention-period"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
with import <nixpkgs> {};
|
||||
stdenv.mkDerivation rec {
|
||||
name = "env";
|
||||
env = buildEnv { name = name; paths = buildInputs; };
|
||||
buildInputs = [
|
||||
dpkg
|
||||
elmPackages.elm
|
||||
elmPackages.elm-format
|
||||
go
|
||||
golint
|
||||
nodejs-10_x
|
||||
rpm
|
||||
swaks
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Inbucket User Interface
|
||||
|
||||
This directory contains the source code for the Inbucket web user interface.
|
||||
It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
|
||||
|
||||
## Development
|
||||
|
||||
With `$INBUCKET` as the root of the git repository.
|
||||
|
||||
One time setup (assuming [Node.js] is already installed):
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
npm i elm -g
|
||||
npm i
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
||||
|
||||
### Terminal 1: inbucket daemon
|
||||
|
||||
```
|
||||
cd $INBUCKET
|
||||
make
|
||||
etc/dev-start.sh
|
||||
```
|
||||
|
||||
Inbucket will start, with HTTP listening on port 9000. You may verify the web
|
||||
UI is functional if this is your first time building Inbucket, but your dev/test
|
||||
cycle should favor the development server below.
|
||||
|
||||
### Terminal 2: webpack development server
|
||||
|
||||
```
|
||||
cd $INBUCKET/ui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
npm will start a development HTTP server listening on port 3000. You should use
|
||||
this server for UI development, as it features hot reload and the Elm debugger.
|
||||
|
||||
[Elm]: https://elm-lang.org
|
||||
[Node.js]: https://nodejs.org
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.0",
|
||||
"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",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.2",
|
||||
"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"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.3",
|
||||
"elm/file": "1.0.1",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"myrho/elm-round": "1.0.4"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
<h1>Welcome to Inbucket</h1>
|
||||
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
|
||||
Generated
+8396
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "inbucket-ui",
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"watch": "webpack --mode development --watch",
|
||||
"dev": "webpack-dev-server --mode development --host 0.0.0.0 --port 3000 --hot",
|
||||
"errors": "webpack --mode development --display-error-details"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.3.3",
|
||||
"@babel/preset-env": "^7.3.1",
|
||||
"@fortawesome/fontawesome-free": "^5.7.2",
|
||||
"@webcomponents/webcomponentsjs": "^2.2.7",
|
||||
"babel-loader": "^8.0.5",
|
||||
"css-loader": "^1.0.1",
|
||||
"elm-hot-webpack-loader": "^1.0.2",
|
||||
"elm-webpack-loader": "^5.0.0",
|
||||
"file-loader": "^3.0.1",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"node-elm-compiler": "^5.0.1",
|
||||
"style-loader": "^0.23.1",
|
||||
"webpack": "^4.29.5",
|
||||
"webpack-cli": "^3.2.3",
|
||||
"webpack-dev-server": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Inbucket",
|
||||
"name": "Inbucket",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
module Api exposing
|
||||
( deleteMessage
|
||||
, getGreeting
|
||||
, getHeaderList
|
||||
, getMessage
|
||||
, getServerConfig
|
||||
, getServerMetrics
|
||||
, markMessageSeen
|
||||
, purgeMailbox
|
||||
, serveUrl
|
||||
)
|
||||
|
||||
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 Http
|
||||
import HttpUtil
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import Url.Builder
|
||||
|
||||
|
||||
type alias DataResult msg data =
|
||||
Result HttpUtil.Error data -> msg
|
||||
|
||||
|
||||
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) []
|
||||
|
||||
|
||||
{-| 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 =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = apiV1Url [ "mailbox", mailboxName ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg (Decode.list MessageHeader.decoder)
|
||||
}
|
||||
|
||||
|
||||
getGreeting : DataResult msg String -> Cmd msg
|
||||
getGreeting msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "greeting" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectString context msg
|
||||
}
|
||||
|
||||
|
||||
getMessage : DataResult msg Message -> String -> String -> Cmd msg
|
||||
getMessage msg mailboxName id =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "mailbox", mailboxName, id ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg Message.decoder
|
||||
}
|
||||
|
||||
|
||||
getServerConfig : DataResult msg ServerConfig -> Cmd msg
|
||||
getServerConfig msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = serveUrl [ "status" ]
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg ServerConfig.decoder
|
||||
}
|
||||
|
||||
|
||||
getServerMetrics : DataResult msg Metrics -> Cmd msg
|
||||
getServerMetrics msg =
|
||||
let
|
||||
context =
|
||||
{ method = "GET"
|
||||
, url = Url.Builder.absolute [ "debug", "vars" ] []
|
||||
}
|
||||
in
|
||||
Http.get
|
||||
{ url = context.url
|
||||
, expect = HttpUtil.expectJson context msg Metrics.decoder
|
||||
}
|
||||
|
||||
|
||||
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg
|
||||
markMessageSeen 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 ])
|
||||
|
||||
|
||||
purgeMailbox : HttpResult msg -> String -> Cmd msg
|
||||
purgeMailbox msg mailboxName =
|
||||
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ])
|
||||
@@ -0,0 +1,20 @@
|
||||
module Data.AppConfig exposing (AppConfig, decoder, default)
|
||||
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline as P
|
||||
|
||||
|
||||
type alias AppConfig =
|
||||
{ monitorVisible : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : D.Decoder AppConfig
|
||||
decoder =
|
||||
D.succeed AppConfig
|
||||
|> P.required "monitor-visible" D.bool
|
||||
|
||||
|
||||
default : AppConfig
|
||||
default =
|
||||
AppConfig True
|
||||
@@ -0,0 +1,11 @@
|
||||
module Data.Date exposing (date)
|
||||
|
||||
import Json.Decode exposing (..)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
{-| Decode a POSIX milliseconds timestamp.
|
||||
-}
|
||||
date : Decoder Posix
|
||||
date =
|
||||
int |> map Time.millisToPosix
|
||||
@@ -0,0 +1,69 @@
|
||||
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias Message =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : Posix
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
, text : String
|
||||
, html : String
|
||||
, attachments : List Attachment
|
||||
, errors : List Error
|
||||
}
|
||||
|
||||
|
||||
type alias Attachment =
|
||||
{ id : String
|
||||
, fileName : String
|
||||
, contentType : String
|
||||
}
|
||||
|
||||
|
||||
type alias Error =
|
||||
{ name : String
|
||||
, detail : String
|
||||
, severe : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Message
|
||||
decoder =
|
||||
succeed Message
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "posix-millis" date
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
|> required "text" string
|
||||
|> required "html" string
|
||||
|> required "attachments" (list attachmentDecoder)
|
||||
|> required "errors" (list errorDecoder)
|
||||
|
||||
|
||||
attachmentDecoder : Decoder Attachment
|
||||
attachmentDecoder =
|
||||
succeed Attachment
|
||||
|> required "id" string
|
||||
|> required "filename" string
|
||||
|> required "content-type" string
|
||||
|
||||
|
||||
errorDecoder : Decoder Error
|
||||
errorDecoder =
|
||||
succeed Error
|
||||
|> required "name" string
|
||||
|> required "detail" string
|
||||
|> required "severe" bool
|
||||
@@ -0,0 +1,31 @@
|
||||
module Data.MessageHeader exposing (MessageHeader, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias MessageHeader =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : Posix
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder MessageHeader
|
||||
decoder =
|
||||
succeed MessageHeader
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "posix-millis" date
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
@@ -0,0 +1,70 @@
|
||||
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
||||
|
||||
import Data.Date exposing (date)
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
type alias Metrics =
|
||||
{ startTime : Posix
|
||||
, sysMem : Int
|
||||
, heapSize : Int
|
||||
, heapUsed : Int
|
||||
, heapObjects : Int
|
||||
, goRoutines : Int
|
||||
, webSockets : Int
|
||||
, smtpConnOpen : Int
|
||||
, smtpConnTotal : Int
|
||||
, smtpConnHist : List Int
|
||||
, smtpReceivedTotal : Int
|
||||
, smtpReceivedHist : List Int
|
||||
, smtpErrorsTotal : Int
|
||||
, smtpErrorsHist : List Int
|
||||
, smtpWarnsTotal : Int
|
||||
, smtpWarnsHist : List Int
|
||||
, retentionDeletesTotal : Int
|
||||
, retentionDeletesHist : List Int
|
||||
, retainedCount : Int
|
||||
, retainedCountHist : List Int
|
||||
, retainedSize : Int
|
||||
, retainedSizeHist : List Int
|
||||
, scanCompleted : Posix
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Metrics
|
||||
decoder =
|
||||
succeed Metrics
|
||||
|> requiredAt [ "startMillis" ] date
|
||||
|> requiredAt [ "memstats", "Sys" ] int
|
||||
|> requiredAt [ "memstats", "HeapSys" ] int
|
||||
|> requiredAt [ "memstats", "HeapAlloc" ] int
|
||||
|> requiredAt [ "memstats", "HeapObjects" ] int
|
||||
|> requiredAt [ "goroutines" ] int
|
||||
|> requiredAt [ "http", "WebSocketConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ReceivedTotal" ] int
|
||||
|> requiredAt [ "smtp", "ReceivedHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ErrorsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ErrorsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "WarnsTotal" ] int
|
||||
|> requiredAt [ "smtp", "WarnsHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "DeletesTotal" ] int
|
||||
|> requiredAt [ "retention", "DeletesHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedCurrent" ] int
|
||||
|> requiredAt [ "retention", "RetainedHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedSize" ] int
|
||||
|> requiredAt [ "retention", "SizeHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "ScanCompletedMillis" ] date
|
||||
|
||||
|
||||
{-| Decodes Inbuckets hacky comma-separated-int JSON strings.
|
||||
-}
|
||||
decodeIntList : Decoder (List Int)
|
||||
decodeIntList =
|
||||
string
|
||||
|> map (String.split ",")
|
||||
|> map (List.map (String.toInt >> Maybe.withDefault 0))
|
||||
@@ -0,0 +1,107 @@
|
||||
module Data.ServerConfig exposing (ServerConfig, decoder, encode)
|
||||
|
||||
import Json.Decode as D
|
||||
import Json.Decode.Pipeline as P
|
||||
import Json.Encode as E
|
||||
|
||||
|
||||
|
||||
-- Generated by https://github.com/jhillyerd/go-to-elm-json
|
||||
|
||||
|
||||
type alias ServerConfig =
|
||||
{ version : String
|
||||
, buildDate : String
|
||||
, pop3Listener : String
|
||||
, webListener : String
|
||||
, smtpConfig : SmtpConfig
|
||||
, storageConfig : StorageConfig
|
||||
}
|
||||
|
||||
|
||||
type alias SmtpConfig =
|
||||
{ addr : String
|
||||
, defaultAccept : Bool
|
||||
, acceptDomains : Maybe (List String)
|
||||
, rejectDomains : Maybe (List String)
|
||||
, defaultStore : Bool
|
||||
, storeDomains : Maybe (List String)
|
||||
, discardDomains : Maybe (List String)
|
||||
}
|
||||
|
||||
|
||||
type alias StorageConfig =
|
||||
{ mailboxMsgCap : Int
|
||||
, storeType : String
|
||||
, retentionPeriod : String
|
||||
}
|
||||
|
||||
|
||||
decoder : D.Decoder ServerConfig
|
||||
decoder =
|
||||
D.succeed ServerConfig
|
||||
|> P.required "version" D.string
|
||||
|> P.required "build-date" D.string
|
||||
|> P.required "pop3-listener" D.string
|
||||
|> P.required "web-listener" D.string
|
||||
|> P.required "smtp-config" smtpConfigDecoder
|
||||
|> P.required "storage-config" storageConfigDecoder
|
||||
|
||||
|
||||
encode : ServerConfig -> E.Value
|
||||
encode r =
|
||||
E.object
|
||||
[ ( "version", E.string r.version )
|
||||
, ( "build-date", E.string r.buildDate )
|
||||
, ( "pop3-listener", E.string r.pop3Listener )
|
||||
, ( "web-listener", E.string r.webListener )
|
||||
, ( "smtp-config", encodeSmtpConfig r.smtpConfig )
|
||||
, ( "storage-config", encodeStorageConfig r.storageConfig )
|
||||
]
|
||||
|
||||
|
||||
smtpConfigDecoder : D.Decoder SmtpConfig
|
||||
smtpConfigDecoder =
|
||||
D.succeed SmtpConfig
|
||||
|> P.required "addr" D.string
|
||||
|> P.required "default-accept" D.bool
|
||||
|> P.required "accept-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "reject-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "default-store" D.bool
|
||||
|> P.required "store-domains" (D.nullable (D.list D.string))
|
||||
|> P.required "discard-domains" (D.nullable (D.list D.string))
|
||||
|
||||
|
||||
encodeSmtpConfig : SmtpConfig -> E.Value
|
||||
encodeSmtpConfig r =
|
||||
E.object
|
||||
[ ( "addr", E.string r.addr )
|
||||
, ( "default-accept", E.bool r.defaultAccept )
|
||||
, ( "accept-domains", maybe (E.list E.string) r.acceptDomains )
|
||||
, ( "reject-domains", maybe (E.list E.string) r.rejectDomains )
|
||||
, ( "default-store", E.bool r.defaultStore )
|
||||
, ( "store-domains", maybe (E.list E.string) r.storeDomains )
|
||||
, ( "discard-domains", maybe (E.list E.string) r.discardDomains )
|
||||
]
|
||||
|
||||
|
||||
storageConfigDecoder : D.Decoder StorageConfig
|
||||
storageConfigDecoder =
|
||||
D.succeed StorageConfig
|
||||
|> P.required "mailbox-msg-cap" D.int
|
||||
|> P.required "store-type" D.string
|
||||
|> P.required "retention-period" D.string
|
||||
|
||||
|
||||
encodeStorageConfig : StorageConfig -> E.Value
|
||||
encodeStorageConfig r =
|
||||
E.object
|
||||
[ ( "mailbox-msg-cap", E.int r.mailboxMsgCap )
|
||||
, ( "store-type", E.string r.storeType )
|
||||
, ( "retention-period", E.string r.retentionPeriod )
|
||||
]
|
||||
|
||||
|
||||
maybe : (a -> E.Value) -> Maybe a -> E.Value
|
||||
maybe encoder =
|
||||
Maybe.map encoder >> Maybe.withDefault E.null
|
||||
@@ -0,0 +1,122 @@
|
||||
module Data.Session exposing
|
||||
( Flash
|
||||
, Persistent
|
||||
, Session
|
||||
, addRecent
|
||||
, clearFlash
|
||||
, decoder
|
||||
, disableRouting
|
||||
, enableRouting
|
||||
, encode
|
||||
, init
|
||||
, initError
|
||||
, showFlash
|
||||
)
|
||||
|
||||
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.Encode as E
|
||||
import Ports
|
||||
import Time
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type alias Session =
|
||||
{ key : Nav.Key
|
||||
, host : String
|
||||
, flash : Maybe Flash
|
||||
, routing : Bool
|
||||
, zone : Time.Zone
|
||||
, config : AppConfig
|
||||
, persistent : Persistent
|
||||
}
|
||||
|
||||
|
||||
type alias Flash =
|
||||
{ title : String
|
||||
, table : List ( String, String )
|
||||
}
|
||||
|
||||
|
||||
type alias Persistent =
|
||||
{ recentMailboxes : List String
|
||||
}
|
||||
|
||||
|
||||
init : Nav.Key -> Url -> AppConfig -> Persistent -> Session
|
||||
init key location config persistent =
|
||||
{ key = key
|
||||
, host = location.host
|
||||
, flash = Nothing
|
||||
, routing = True
|
||||
, zone = Time.utc
|
||||
, config = config
|
||||
, persistent = persistent
|
||||
}
|
||||
|
||||
|
||||
initError : Nav.Key -> Url -> String -> Session
|
||||
initError key location error =
|
||||
{ key = key
|
||||
, host = location.host
|
||||
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
||||
, routing = True
|
||||
, zone = Time.utc
|
||||
, config = AppConfig.default
|
||||
, persistent = Persistent []
|
||||
}
|
||||
|
||||
|
||||
addRecent : String -> Session -> Session
|
||||
addRecent mailbox session =
|
||||
if List.head session.persistent.recentMailboxes == Just mailbox then
|
||||
session
|
||||
|
||||
else
|
||||
let
|
||||
recent =
|
||||
session.persistent.recentMailboxes
|
||||
|> List.filter ((/=) mailbox)
|
||||
|> List.take 7
|
||||
|> (::) mailbox
|
||||
|
||||
persistent =
|
||||
session.persistent
|
||||
in
|
||||
{ session | persistent = { persistent | recentMailboxes = recent } }
|
||||
|
||||
|
||||
disableRouting : Session -> Session
|
||||
disableRouting session =
|
||||
{ session | routing = False }
|
||||
|
||||
|
||||
enableRouting : Session -> Session
|
||||
enableRouting session =
|
||||
{ session | routing = True }
|
||||
|
||||
|
||||
clearFlash : Session -> Session
|
||||
clearFlash session =
|
||||
{ session | flash = Nothing }
|
||||
|
||||
|
||||
showFlash : Flash -> Session -> Session
|
||||
showFlash flash session =
|
||||
{ session | flash = Just flash }
|
||||
|
||||
|
||||
decoder : D.Decoder Persistent
|
||||
decoder =
|
||||
D.succeed Persistent
|
||||
|> optional "recentMailboxes" (D.list D.string) []
|
||||
|
||||
|
||||
encode : Persistent -> E.Value
|
||||
encode persistent =
|
||||
E.object
|
||||
[ ( "recentMailboxes", E.list E.string persistent.recentMailboxes )
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
|
||||
|
||||
type alias Error =
|
||||
{ error : Http.Error
|
||||
, request : RequestContext
|
||||
}
|
||||
|
||||
|
||||
type alias RequestContext =
|
||||
{ method : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
|
||||
delete : (Result Error () -> msg) -> String -> Cmd msg
|
||||
delete msg url =
|
||||
let
|
||||
context =
|
||||
{ method = "DELETE"
|
||||
, url = url
|
||||
}
|
||||
in
|
||||
Http.request
|
||||
{ method = context.method
|
||||
, headers = []
|
||||
, url = url
|
||||
, body = Http.emptyBody
|
||||
, expect = expectWhatever context msg
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
patch : (Result Error () -> msg) -> String -> Http.Body -> Cmd msg
|
||||
patch msg url body =
|
||||
let
|
||||
context =
|
||||
{ method = "PATCH"
|
||||
, url = url
|
||||
}
|
||||
in
|
||||
Http.request
|
||||
{ method = context.method
|
||||
, headers = []
|
||||
, url = url
|
||||
, body = body
|
||||
, expect = expectWhatever context msg
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
errorFlash : Error -> Session.Flash
|
||||
errorFlash error =
|
||||
let
|
||||
requestContext flash =
|
||||
{ flash
|
||||
| table =
|
||||
flash.table
|
||||
++ [ ( "Request URL", error.request.url )
|
||||
, ( "HTTP Method", error.request.method )
|
||||
]
|
||||
}
|
||||
in
|
||||
requestContext <|
|
||||
case error.error of
|
||||
Http.BadUrl str ->
|
||||
{ title = "Bad URL"
|
||||
, table = [ ( "URL", str ) ]
|
||||
}
|
||||
|
||||
Http.Timeout ->
|
||||
{ title = "HTTP timeout"
|
||||
, table = []
|
||||
}
|
||||
|
||||
Http.NetworkError ->
|
||||
{ title = "HTTP network error"
|
||||
, table = []
|
||||
}
|
||||
|
||||
Http.BadStatus res ->
|
||||
{ title = "HTTP response error"
|
||||
, table = [ ( "Response Code", String.fromInt res ) ]
|
||||
}
|
||||
|
||||
Http.BadBody body ->
|
||||
{ title = "Bad HTTP body"
|
||||
, table = [ ( "Error", body ) ]
|
||||
}
|
||||
|
||||
|
||||
expectJson : RequestContext -> (Result Error a -> msg) -> Decode.Decoder a -> Http.Expect msg
|
||||
expectJson context toMsg decoder =
|
||||
Http.expectStringResponse toMsg <|
|
||||
resolve context <|
|
||||
\string ->
|
||||
Result.mapError Decode.errorToString (Decode.decodeString decoder string)
|
||||
|
||||
|
||||
expectString : RequestContext -> (Result Error String -> msg) -> Http.Expect msg
|
||||
expectString context toMsg =
|
||||
Http.expectStringResponse toMsg (resolve context Ok)
|
||||
|
||||
|
||||
expectWhatever : RequestContext -> (Result Error () -> msg) -> Http.Expect msg
|
||||
expectWhatever context toMsg =
|
||||
Http.expectBytesResponse toMsg (resolve context (\_ -> Ok ()))
|
||||
|
||||
|
||||
resolve : RequestContext -> (body -> Result String a) -> Http.Response body -> Result Error a
|
||||
resolve context toResult response =
|
||||
case response of
|
||||
Http.BadUrl_ url ->
|
||||
Err (Error (Http.BadUrl url) context)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err (Error Http.Timeout context)
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err (Error Http.NetworkError context)
|
||||
|
||||
Http.BadStatus_ metadata _ ->
|
||||
Err (Error (Http.BadStatus metadata.statusCode) context)
|
||||
|
||||
Http.GoodStatus_ _ body ->
|
||||
Result.mapError (\x -> Error (Http.BadBody x) context) (toResult body)
|
||||
@@ -0,0 +1,269 @@
|
||||
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
||||
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( attribute
|
||||
, class
|
||||
, classList
|
||||
, href
|
||||
, id
|
||||
, placeholder
|
||||
, rel
|
||||
, selected
|
||||
, target
|
||||
, type_
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import Route exposing (Route)
|
||||
|
||||
|
||||
{-| Used to highlight current page in navbar.
|
||||
-}
|
||||
type Page
|
||||
= Other
|
||||
| Mailbox
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
type alias Model msg =
|
||||
{ mapMsg : Msg -> msg
|
||||
, menuVisible : Bool
|
||||
, recentVisible : Bool
|
||||
, mailboxName : String
|
||||
}
|
||||
|
||||
|
||||
init : (Msg -> msg) -> Model msg
|
||||
init mapMsg =
|
||||
{ mapMsg = mapMsg
|
||||
, menuVisible = False
|
||||
, recentVisible = False
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
|
||||
{-| Resets layout state, used when navigating to a new page.
|
||||
-}
|
||||
reset : Model msg -> Model msg
|
||||
reset model =
|
||||
{ model
|
||||
| menuVisible = False
|
||||
, recentVisible = False
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= ClearFlash
|
||||
| OnMailboxNameInput String
|
||||
| OpenMailbox
|
||||
| ShowRecent Bool
|
||||
| ToggleMenu
|
||||
|
||||
|
||||
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
|
||||
update msg model session =
|
||||
case msg of
|
||||
ClearFlash ->
|
||||
( model
|
||||
, Session.clearFlash session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
OnMailboxNameInput name ->
|
||||
( { model | mailboxName = name }
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
OpenMailbox ->
|
||||
if model.mailboxName == "" then
|
||||
( model, session, Cmd.none )
|
||||
|
||||
else
|
||||
( model
|
||||
, session
|
||||
, Route.pushUrl session.key (Route.Mailbox model.mailboxName)
|
||||
)
|
||||
|
||||
ShowRecent visible ->
|
||||
( { model | recentVisible = visible }
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ToggleMenu ->
|
||||
( { model | menuVisible = not model.menuVisible }
|
||||
, session
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
type alias State msg =
|
||||
{ model : Model msg
|
||||
, session : Session
|
||||
, activePage : Page
|
||||
, activeMailbox : String
|
||||
, modal : Maybe (Html msg)
|
||||
, content : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
frame : State msg -> Html msg
|
||||
frame { model, session, activePage, activeMailbox, modal, content } =
|
||||
div [ class "app" ]
|
||||
[ header []
|
||||
[ nav [ class "navbar" ]
|
||||
[ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> 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 ) ] ]
|
||||
[ if session.config.monitorVisible then
|
||||
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage
|
||||
|
||||
else
|
||||
text ""
|
||||
, navbarLink Status Route.Status [ text "Status" ] activePage
|
||||
, navbarRecent activePage activeMailbox model session
|
||||
, li [ class "navbar-mailbox" ]
|
||||
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, placeholder "mailbox"
|
||||
, value model.mailboxName
|
||||
, Events.onInput (OnMailboxNameInput >> model.mapMsg)
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "navbar-bg" ] [ text "" ]
|
||||
, frameModal modal
|
||||
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content)
|
||||
, footer []
|
||||
[ div [ class "footer" ]
|
||||
[ externalLink "https://www.inbucket.org" "Inbucket"
|
||||
, text " is an open source project hosted on "
|
||||
, externalLink "https://github.com/inbucket/inbucket" "GitHub"
|
||||
, text "."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
row ( heading, message ) =
|
||||
tr []
|
||||
[ th [] [ text (heading ++ ":") ]
|
||||
, td [] [ pre [] [ text message ] ]
|
||||
]
|
||||
in
|
||||
case maybeFlash of
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
Just flash ->
|
||||
div [ class "well well-error" ]
|
||||
[ div [ class "flash-header" ]
|
||||
[ h2 [] [ text flash.title ]
|
||||
, a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ]
|
||||
]
|
||||
, div [ class "flash-table" ] (List.map row flash.table)
|
||||
]
|
||||
|
||||
|
||||
externalLink : String -> String -> Html a
|
||||
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 =
|
||||
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
||||
[ a [ Route.href route ] linkContent ]
|
||||
|
||||
|
||||
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||
-}
|
||||
navbarRecent : Page -> String -> Model msg -> Session -> Html msg
|
||||
navbarRecent page activeMailbox model session =
|
||||
let
|
||||
-- Active means we are viewing a specific mailbox.
|
||||
active =
|
||||
page == Mailbox
|
||||
|
||||
-- Recent tab title is the name of the current mailbox when active.
|
||||
title =
|
||||
if active then
|
||||
activeMailbox
|
||||
|
||||
else
|
||||
"Recent Mailboxes"
|
||||
|
||||
-- Mailboxes to show in recent list, doesn't include active mailbox.
|
||||
recentMailboxes =
|
||||
if active then
|
||||
List.tail session.persistent.recentMailboxes |> Maybe.withDefault []
|
||||
|
||||
else
|
||||
session.persistent.recentMailboxes
|
||||
|
||||
dropdownExpanded =
|
||||
if model.recentVisible then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
|
||||
recentLink mailbox =
|
||||
a [ Route.href (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)
|
||||
]
|
||||
[ span [ class "navbar-dropdown" ]
|
||||
[ text title
|
||||
, button
|
||||
[ class "navbar-dropdown-button"
|
||||
, Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg)
|
||||
]
|
||||
[ i [ class "fas fa-chevron-down" ] [] ]
|
||||
]
|
||||
, div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
|
||||
]
|
||||
|
||||
|
||||
ariaExpanded : Bool -> Attribute msg
|
||||
ariaExpanded value =
|
||||
attribute "aria-expanded" <|
|
||||
if value then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
module Main exposing (main)
|
||||
|
||||
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 Json.Decode as D exposing (Value)
|
||||
import Layout
|
||||
import Page.Home as Home
|
||||
import Page.Mailbox as Mailbox
|
||||
import Page.Monitor as Monitor
|
||||
import Page.Status as Status
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
import Task
|
||||
import Time
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ layout : Layout.Model Msg
|
||||
, page : PageModel
|
||||
}
|
||||
|
||||
|
||||
type PageModel
|
||||
= Home Home.Model
|
||||
| Mailbox Mailbox.Model
|
||||
| Monitor Monitor.Model
|
||||
| Status Status.Model
|
||||
|
||||
|
||||
type alias InitConfig =
|
||||
{ appConfig : AppConfig
|
||||
, session : Session.Persistent
|
||||
}
|
||||
|
||||
|
||||
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init configValue location key =
|
||||
let
|
||||
configDecoder =
|
||||
D.map2 InitConfig
|
||||
(D.field "app-config" AppConfig.decoder)
|
||||
(D.field "session" Session.decoder)
|
||||
|
||||
session =
|
||||
case D.decodeValue configDecoder configValue of
|
||||
Ok config ->
|
||||
Session.init key location config.appConfig config.session
|
||||
|
||||
Err error ->
|
||||
Session.initError key location (D.errorToString error)
|
||||
|
||||
( subModel, _ ) =
|
||||
Home.init session
|
||||
|
||||
initModel =
|
||||
{ layout = Layout.init LayoutMsg
|
||||
, page = Home subModel
|
||||
}
|
||||
|
||||
route =
|
||||
Route.fromUrl location
|
||||
|
||||
( model, cmd ) =
|
||||
changeRouteTo route initModel
|
||||
in
|
||||
( model, Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ] )
|
||||
|
||||
|
||||
type Msg
|
||||
= UrlChanged Url
|
||||
| LinkClicked UrlRequest
|
||||
| SessionUpdated (Result D.Error Session.Persistent)
|
||||
| TimeZoneLoaded Time.Zone
|
||||
| LayoutMsg Layout.Msg
|
||||
| HomeMsg Home.Msg
|
||||
| MailboxMsg Mailbox.Msg
|
||||
| MonitorMsg Monitor.Msg
|
||||
| StatusMsg Status.Msg
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ pageSubscriptions model.page
|
||||
, Sub.map SessionUpdated sessionChange
|
||||
]
|
||||
|
||||
|
||||
sessionChange : Sub (Result D.Error Session.Persistent)
|
||||
sessionChange =
|
||||
Ports.onSessionChange (D.decodeValue Session.decoder)
|
||||
|
||||
|
||||
pageSubscriptions : PageModel -> Sub Msg
|
||||
pageSubscriptions page =
|
||||
case page of
|
||||
Mailbox subModel ->
|
||||
Sub.map MailboxMsg (Mailbox.subscriptions subModel)
|
||||
|
||||
Status subModel ->
|
||||
Sub.map StatusMsg (Status.subscriptions subModel)
|
||||
|
||||
_ ->
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
let
|
||||
session =
|
||||
getSession model
|
||||
|
||||
( newModel, cmd ) =
|
||||
updateMain msg model session
|
||||
|
||||
newSession =
|
||||
getSession newModel
|
||||
in
|
||||
if session.persistent == newSession.persistent then
|
||||
( newModel, cmd )
|
||||
|
||||
else
|
||||
-- Store updated persistent session.
|
||||
( newModel
|
||||
, Cmd.batch
|
||||
[ Ports.storeSession (Session.encode newSession.persistent)
|
||||
, cmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
{-| Handle global/navbar related msgs.
|
||||
-}
|
||||
updateMain : Msg -> Model -> Session -> ( Model, Cmd Msg )
|
||||
updateMain msg model session =
|
||||
case msg of
|
||||
LinkClicked req ->
|
||||
case req of
|
||||
Browser.Internal url ->
|
||||
case url.fragment of
|
||||
Just "" ->
|
||||
-- Anchor tag for accessibility purposes only, already handled.
|
||||
( model, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Nav.pushUrl session.key (Url.toString url) )
|
||||
|
||||
Browser.External url ->
|
||||
( model, Nav.load url )
|
||||
|
||||
UrlChanged url ->
|
||||
-- Responds to new browser URL.
|
||||
if session.routing then
|
||||
changeRouteTo (Route.fromUrl url) model
|
||||
|
||||
else
|
||||
-- Skip once, but re-enable routing.
|
||||
( applyToModelSession Session.enableRouting model
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SessionUpdated (Ok persistent) ->
|
||||
( updateSession model { session | persistent = persistent }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SessionUpdated (Err error) ->
|
||||
let
|
||||
flash =
|
||||
{ title = "Error decoding session"
|
||||
, table = [ ( "Error", D.errorToString error ) ]
|
||||
}
|
||||
in
|
||||
( applyToModelSession (Session.showFlash flash) model
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
TimeZoneLoaded zone ->
|
||||
( updateSession model { session | zone = zone }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
LayoutMsg subMsg ->
|
||||
let
|
||||
( layout, newSession, cmd ) =
|
||||
Layout.update subMsg model.layout session
|
||||
in
|
||||
( updateSession { model | layout = layout } newSession
|
||||
, cmd
|
||||
)
|
||||
|
||||
_ ->
|
||||
updatePage msg model
|
||||
|
||||
|
||||
{-| Delegate incoming messages to their respective sub-pages.
|
||||
-}
|
||||
updatePage : Msg -> Model -> ( Model, Cmd Msg )
|
||||
updatePage msg model =
|
||||
case ( msg, model.page ) of
|
||||
( HomeMsg subMsg, Home subModel ) ->
|
||||
Home.update subMsg subModel
|
||||
|> updateWith Home HomeMsg model
|
||||
|
||||
( MailboxMsg subMsg, Mailbox subModel ) ->
|
||||
Mailbox.update subMsg subModel
|
||||
|> updateWith Mailbox MailboxMsg model
|
||||
|
||||
( MonitorMsg subMsg, Monitor subModel ) ->
|
||||
Monitor.update subMsg subModel
|
||||
|> updateWith Monitor MonitorMsg model
|
||||
|
||||
( StatusMsg subMsg, Status subModel ) ->
|
||||
Status.update subMsg subModel
|
||||
|> updateWith Status StatusMsg model
|
||||
|
||||
( _, _ ) ->
|
||||
-- Disregard messages destined for the wrong page.
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
|
||||
changeRouteTo route model =
|
||||
let
|
||||
session =
|
||||
getSession model |> Session.clearFlash
|
||||
|
||||
newModel =
|
||||
{ model | layout = Layout.reset model.layout }
|
||||
in
|
||||
case route of
|
||||
Route.Home ->
|
||||
Home.init session
|
||||
|> updateWith Home HomeMsg newModel
|
||||
|
||||
Route.Mailbox name ->
|
||||
Mailbox.init session name Nothing
|
||||
|> updateWith Mailbox MailboxMsg newModel
|
||||
|
||||
Route.Message mailbox id ->
|
||||
Mailbox.init session mailbox (Just id)
|
||||
|> updateWith Mailbox MailboxMsg newModel
|
||||
|
||||
Route.Monitor ->
|
||||
if session.config.monitorVisible then
|
||||
Monitor.init session
|
||||
|> updateWith Monitor MonitorMsg newModel
|
||||
|
||||
else
|
||||
let
|
||||
flash =
|
||||
{ title = "Disabled route requested"
|
||||
, table = [ ( "Error", "Monitor disabled by configuration." ) ]
|
||||
}
|
||||
in
|
||||
( applyToModelSession (Session.showFlash flash) newModel
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Route.Status ->
|
||||
Status.init session
|
||||
|> updateWith Status StatusMsg newModel
|
||||
|
||||
Route.Unknown path ->
|
||||
-- Unknown routes display Home with an error flash.
|
||||
let
|
||||
flash =
|
||||
{ title = "Unknown route requested"
|
||||
, table = [ ( "Path", path ) ]
|
||||
}
|
||||
in
|
||||
Home.init (Session.showFlash flash session)
|
||||
|> updateWith Home HomeMsg newModel
|
||||
|
||||
|
||||
getSession : Model -> Session
|
||||
getSession model =
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
subModel.session
|
||||
|
||||
Mailbox subModel ->
|
||||
subModel.session
|
||||
|
||||
Monitor subModel ->
|
||||
subModel.session
|
||||
|
||||
Status subModel ->
|
||||
subModel.session
|
||||
|
||||
|
||||
updateSession : Model -> Session -> Model
|
||||
updateSession model session =
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
{ model | page = Home { subModel | session = session } }
|
||||
|
||||
Mailbox subModel ->
|
||||
{ model | page = Mailbox { subModel | session = session } }
|
||||
|
||||
Monitor subModel ->
|
||||
{ model | page = Monitor { subModel | session = session } }
|
||||
|
||||
Status subModel ->
|
||||
{ model | page = Status { subModel | session = session } }
|
||||
|
||||
|
||||
applyToModelSession : (Session -> Session) -> Model -> Model
|
||||
applyToModelSession f model =
|
||||
updateSession model (f (getSession model))
|
||||
|
||||
|
||||
{-| Map page updates to Main Model and Msg types.
|
||||
-}
|
||||
updateWith :
|
||||
(subModel -> PageModel)
|
||||
-> (subMsg -> Msg)
|
||||
-> Model
|
||||
-> ( subModel, Cmd subMsg )
|
||||
-> ( Model, Cmd Msg )
|
||||
updateWith toPage toMsg model ( subModel, subCmd ) =
|
||||
( { model | page = toPage subModel }
|
||||
, Cmd.map toMsg subCmd
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
let
|
||||
session =
|
||||
getSession model
|
||||
|
||||
mailbox =
|
||||
case model.page of
|
||||
Mailbox subModel ->
|
||||
subModel.mailboxName
|
||||
|
||||
_ ->
|
||||
""
|
||||
|
||||
framePage :
|
||||
Layout.Page
|
||||
-> (msg -> Msg)
|
||||
-> { title : String, modal : Maybe (Html msg), content : List (Html msg) }
|
||||
-> Document Msg
|
||||
framePage page toMsg { title, modal, content } =
|
||||
Document title
|
||||
[ Layout.frame
|
||||
{ model = model.layout
|
||||
, session = session
|
||||
, activePage = page
|
||||
, activeMailbox = mailbox
|
||||
, modal = Maybe.map (Html.map toMsg) modal
|
||||
, content = List.map (Html.map toMsg) content
|
||||
}
|
||||
]
|
||||
in
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
framePage Layout.Other HomeMsg (Home.view subModel)
|
||||
|
||||
Mailbox subModel ->
|
||||
framePage Layout.Mailbox MailboxMsg (Mailbox.view subModel)
|
||||
|
||||
Monitor subModel ->
|
||||
framePage Layout.Monitor MonitorMsg (Monitor.view subModel)
|
||||
|
||||
Status subModel ->
|
||||
framePage Layout.Status StatusMsg (Status.view subModel)
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Value Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlChange = UrlChanged
|
||||
, onUrlRequest = LinkClicked
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 HttpUtil
|
||||
import Json.Encode as Encode
|
||||
import Ports
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, greeting : String
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Cmd Msg )
|
||||
init session =
|
||||
( Model session "", Api.getGreeting GreetingLoaded )
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= GreetingLoaded (Result HttpUtil.Error String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GreetingLoaded (Ok greeting) ->
|
||||
( { model | greeting = greeting }, Cmd.none )
|
||||
|
||||
GreetingLoaded (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ Html.node "rendered-html"
|
||||
[ class "greeting"
|
||||
, property "content" (Encode.string model.greeting)
|
||||
]
|
||||
[]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view)
|
||||
|
||||
import Api
|
||||
import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import DateFormat as DF
|
||||
import DateFormat.Relative as Relative
|
||||
import Html exposing (..)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( alt
|
||||
, class
|
||||
, classList
|
||||
, disabled
|
||||
, download
|
||||
, href
|
||||
, id
|
||||
, placeholder
|
||||
, property
|
||||
, tabindex
|
||||
, target
|
||||
, type_
|
||||
, 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 Route
|
||||
import Task
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type Body
|
||||
= TextBody
|
||||
| SafeHtmlBody
|
||||
|
||||
|
||||
type State
|
||||
= LoadingList (Maybe MessageID)
|
||||
| ShowingList MessageList MessageState
|
||||
|
||||
|
||||
type MessageState
|
||||
= NoMessage
|
||||
| LoadingMessage
|
||||
| ShowingMessage VisibleMessage
|
||||
| Transitioning VisibleMessage
|
||||
|
||||
|
||||
type alias MessageID =
|
||||
String
|
||||
|
||||
|
||||
type alias MessageList =
|
||||
{ headers : List MessageHeader
|
||||
, selected : Maybe MessageID
|
||||
, searchFilter : String
|
||||
}
|
||||
|
||||
|
||||
type alias VisibleMessage =
|
||||
{ message : Message
|
||||
, markSeenAt : Maybe Int
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, mailboxName : String
|
||||
, state : State
|
||||
, bodyMode : Body
|
||||
, searchInput : String
|
||||
, promptPurge : Bool
|
||||
, now : Posix
|
||||
}
|
||||
|
||||
|
||||
init : Session -> String -> Maybe MessageID -> ( Model, Cmd Msg )
|
||||
init session mailboxName selection =
|
||||
( { session = session
|
||||
, mailboxName = mailboxName
|
||||
, state = LoadingList selection
|
||||
, bodyMode = SafeHtmlBody
|
||||
, searchInput = ""
|
||||
, promptPurge = False
|
||||
, now = Time.millisToPosix 0
|
||||
}
|
||||
, load mailboxName
|
||||
)
|
||||
|
||||
|
||||
load : String -> Cmd Msg
|
||||
load mailboxName =
|
||||
Cmd.batch
|
||||
[ Task.perform Tick Time.now
|
||||
, Api.getHeaderList ListLoaded mailboxName
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
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 ())
|
||||
| DeleteMessage Message
|
||||
| DeletedMessage (Result HttpUtil.Error ())
|
||||
| PurgeMailboxPrompt
|
||||
| PurgeMailboxCanceled
|
||||
| PurgeMailboxConfirmed
|
||||
| PurgedMailbox (Result HttpUtil.Error ())
|
||||
| OnSearchInput String
|
||||
| Tick Posix
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ClickMessage id ->
|
||||
( 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
|
||||
]
|
||||
)
|
||||
|
||||
OpenMessage id ->
|
||||
updateOpenMessage model id
|
||||
|
||||
CloseMessage ->
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
( { model | state = ShowingList list NoMessage }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
DeleteMessage message ->
|
||||
updateDeleteMessage model message
|
||||
|
||||
DeletedMessage (Ok _) ->
|
||||
( model, Cmd.none )
|
||||
|
||||
DeletedMessage (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ListKeyPress id keyCode ->
|
||||
case keyCode of
|
||||
13 ->
|
||||
updateOpenMessage model id
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ListLoaded (Ok headers) ->
|
||||
case model.state of
|
||||
LoadingList selection ->
|
||||
let
|
||||
newModel =
|
||||
{ model
|
||||
| state = ShowingList (MessageList headers Nothing "") NoMessage
|
||||
}
|
||||
in
|
||||
case selection of
|
||||
Just id ->
|
||||
updateOpenMessage newModel id
|
||||
|
||||
Nothing ->
|
||||
( { newModel
|
||||
| session = Session.addRecent model.mailboxName model.session
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ListLoaded (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MarkedSeen (Ok _) ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MarkedSeen (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MessageLoaded (Ok message) ->
|
||||
updateMessageResult model message
|
||||
|
||||
MessageLoaded (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
MessageBody bodyMode ->
|
||||
( { model | bodyMode = bodyMode }, 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 )
|
||||
|
||||
PurgeMailboxCanceled ->
|
||||
( { model | promptPurge = False }, Cmd.none )
|
||||
|
||||
PurgeMailboxConfirmed ->
|
||||
updatePurge model
|
||||
|
||||
PurgedMailbox (Ok _) ->
|
||||
( model, Cmd.none )
|
||||
|
||||
PurgedMailbox (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, 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
|
||||
|
||||
else
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Tick now ->
|
||||
( { model | now = now }, Cmd.none )
|
||||
|
||||
|
||||
{-| Replace the currently displayed message.
|
||||
-}
|
||||
updateMessageResult : Model -> Message -> ( Model, Cmd Msg )
|
||||
updateMessageResult model message =
|
||||
let
|
||||
bodyMode =
|
||||
if message.html == "" then
|
||||
TextBody
|
||||
|
||||
else
|
||||
model.bodyMode
|
||||
in
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ShowingList list _ ->
|
||||
( { model
|
||||
| state =
|
||||
ShowingList
|
||||
{ list | selected = Just message.id }
|
||||
(ShowingMessage (VisibleMessage message Nothing))
|
||||
, bodyMode = bodyMode
|
||||
}
|
||||
, Task.perform OpenedTime Time.now
|
||||
)
|
||||
|
||||
|
||||
updatePurge : Model -> ( Model, Cmd Msg )
|
||||
updatePurge model =
|
||||
let
|
||||
cmd =
|
||||
Cmd.batch
|
||||
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
|
||||
, Api.purgeMailbox PurgedMailbox model.mailboxName
|
||||
]
|
||||
in
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
( { model
|
||||
| promptPurge = False
|
||||
, session = Session.disableRouting model.session
|
||||
, state = ShowingList (MessageList [] Nothing "") NoMessage
|
||||
}
|
||||
, cmd
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, cmd )
|
||||
|
||||
|
||||
updateSearchInput : Model -> String -> ( Model, Cmd Msg )
|
||||
updateSearchInput model searchInput =
|
||||
let
|
||||
searchFilter =
|
||||
if String.length searchInput > 1 then
|
||||
String.toLower searchInput
|
||||
|
||||
else
|
||||
""
|
||||
in
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ShowingList list messageState ->
|
||||
( { model
|
||||
| searchInput = searchInput
|
||||
, state = ShowingList { list | searchFilter = searchFilter } messageState
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
{-| Set the selected message in our model.
|
||||
-}
|
||||
updateSelected : Model -> MessageID -> Model
|
||||
updateSelected model id =
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
model
|
||||
|
||||
ShowingList list messageState ->
|
||||
let
|
||||
newList =
|
||||
{ list | selected = Just id }
|
||||
in
|
||||
case messageState of
|
||||
NoMessage ->
|
||||
{ model | state = ShowingList newList LoadingMessage }
|
||||
|
||||
LoadingMessage ->
|
||||
{ model | state = ShowingList newList LoadingMessage }
|
||||
|
||||
ShowingMessage visible ->
|
||||
-- Use Transitioning state to prevent blank message flicker.
|
||||
{ model | state = ShowingList newList (Transitioning visible) }
|
||||
|
||||
Transitioning visible ->
|
||||
{ model | state = ShowingList newList (Transitioning visible) }
|
||||
|
||||
|
||||
updateDeleteMessage : Model -> Message -> ( Model, Cmd Msg )
|
||||
updateDeleteMessage model message =
|
||||
let
|
||||
filter f messageList =
|
||||
{ messageList | headers = List.filter f messageList.headers }
|
||||
in
|
||||
case model.state of
|
||||
ShowingList list _ ->
|
||||
( { model
|
||||
| session = Session.disableRouting model.session
|
||||
, state =
|
||||
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)
|
||||
]
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
updateMarkMessageSeen : Model -> Message -> ( Model, Cmd Msg )
|
||||
updateMarkMessageSeen model message =
|
||||
case model.state of
|
||||
ShowingList list (ShowingMessage visible) ->
|
||||
let
|
||||
updateSeen header =
|
||||
if header.id == message.id then
|
||||
{ header | seen = True }
|
||||
|
||||
else
|
||||
header
|
||||
|
||||
map f messageList =
|
||||
{ messageList | headers = List.map f messageList.headers }
|
||||
in
|
||||
( { model
|
||||
| state =
|
||||
ShowingList (map updateSeen list)
|
||||
(ShowingMessage
|
||||
{ visible
|
||||
| message = { message | seen = True }
|
||||
, markSeenAt = Nothing
|
||||
}
|
||||
)
|
||||
}
|
||||
, Api.markMessageSeen MarkedSeen message.mailbox message.id
|
||||
)
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
updateOpenMessage : Model -> String -> ( Model, Cmd Msg )
|
||||
updateOpenMessage model id =
|
||||
let
|
||||
newModel =
|
||||
{ model | session = Session.addRecent model.mailboxName model.session }
|
||||
in
|
||||
( updateSelected newModel id
|
||||
, Api.getMessage MessageLoaded model.mailboxName id
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html Msg), content : List (Html Msg) }
|
||||
view model =
|
||||
let
|
||||
mode =
|
||||
case model.state of
|
||||
ShowingList _ (ShowingMessage _) ->
|
||||
"message-active"
|
||||
|
||||
_ ->
|
||||
"list-active"
|
||||
in
|
||||
{ title = model.mailboxName ++ " - Inbucket"
|
||||
, modal = viewModal model.promptPurge
|
||||
, content =
|
||||
[ div [ class ("mailbox " ++ mode) ]
|
||||
[ aside [ class "message-list-controls" ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, placeholder "search"
|
||||
, Events.onInput OnSearchInput
|
||||
, value model.searchInput
|
||||
]
|
||||
[]
|
||||
, button
|
||||
[ Events.onClick (OnSearchInput "")
|
||||
, disabled (model.searchInput == "")
|
||||
, alt "Clear Search"
|
||||
]
|
||||
[ i [ class "fas fa-times" ] [] ]
|
||||
, button
|
||||
[ Events.onClick PurgeMailboxPrompt
|
||||
, alt "Purge Mailbox"
|
||||
]
|
||||
[ i [ class "fas fa-trash" ] [] ]
|
||||
]
|
||||
, viewMessageList model
|
||||
, main_
|
||||
[ class "message" ]
|
||||
[ case model.state of
|
||||
ShowingList _ NoMessage ->
|
||||
text
|
||||
("Select a message on the left,"
|
||||
++ " or enter a different username into the box on upper right."
|
||||
)
|
||||
|
||||
ShowingList _ (ShowingMessage { message }) ->
|
||||
viewMessage model.session.zone message model.bodyMode
|
||||
|
||||
ShowingList _ (Transitioning { message }) ->
|
||||
viewMessage model.session.zone message model.bodyMode
|
||||
|
||||
_ ->
|
||||
text ""
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewModal : Bool -> Maybe (Html Msg)
|
||||
viewModal promptPurge =
|
||||
if promptPurge then
|
||||
Just <|
|
||||
div []
|
||||
[ p [] [ text "Are you sure you want to delete all messages in this mailbox?" ]
|
||||
, div [ class "button-bar" ]
|
||||
[ button [ Events.onClick PurgeMailboxConfirmed, class "danger" ] [ text "Yes" ]
|
||||
, button [ Events.onClick PurgeMailboxCanceled ] [ text "Cancel" ]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
|
||||
viewMessageList : Model -> Html Msg
|
||||
viewMessageList model =
|
||||
aside [ class "message-list" ] <|
|
||||
case model.state of
|
||||
LoadingList _ ->
|
||||
[]
|
||||
|
||||
ShowingList list _ ->
|
||||
list
|
||||
|> filterMessageList
|
||||
|> List.reverse
|
||||
|> List.map (messageChip model list.selected)
|
||||
|
||||
|
||||
messageChip : Model -> Maybe MessageID -> MessageHeader -> Html Msg
|
||||
messageChip model selected message =
|
||||
div
|
||||
[ class "message-list-entry"
|
||||
, classList
|
||||
[ ( "selected", selected == Just message.id )
|
||||
, ( "unseen", not message.seen )
|
||||
]
|
||||
, Events.onClick (ClickMessage message.id)
|
||||
, onKeyUp (ListKeyPress message.id)
|
||||
, tabindex 0
|
||||
]
|
||||
[ div [ class "subject" ] [ text message.subject ]
|
||||
, div [ class "from" ] [ text message.from ]
|
||||
, div [ class "date" ] [ relativeDate model message.date ]
|
||||
]
|
||||
|
||||
|
||||
viewMessage : Time.Zone -> Message -> Body -> Html Msg
|
||||
viewMessage zone message bodyMode =
|
||||
let
|
||||
htmlUrl =
|
||||
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
|
||||
|
||||
sourceUrl =
|
||||
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
|
||||
|
||||
htmlButton =
|
||||
if message.html == "" then
|
||||
text ""
|
||||
|
||||
else
|
||||
a [ href htmlUrl, target "_blank" ]
|
||||
[ button [ tabindex -1 ] [ text "Raw HTML" ] ]
|
||||
in
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "message-close light", Events.onClick CloseMessage ]
|
||||
[ i [ class "fas fa-arrow-left" ] [] ]
|
||||
, button [ class "danger", Events.onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a [ href sourceUrl, target "_blank" ]
|
||||
[ button [ tabindex -1 ] [ text "Source" ] ]
|
||||
, htmlButton
|
||||
]
|
||||
, dl [ class "message-header" ]
|
||||
[ dt [] [ text "From:" ]
|
||||
, dd [] [ text message.from ]
|
||||
, dt [] [ text "To:" ]
|
||||
, dd [] [ text (String.join ", " message.to) ]
|
||||
, dt [] [ text "Date:" ]
|
||||
, dd [] [ verboseDate zone message.date ]
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, messageErrors message
|
||||
, messageBody message bodyMode
|
||||
, attachments message
|
||||
]
|
||||
|
||||
|
||||
messageErrors : Message -> Html Msg
|
||||
messageErrors message =
|
||||
let
|
||||
row error =
|
||||
li []
|
||||
[ span
|
||||
[ classList [ ( "message-warn-severe", error.severe ) ] ]
|
||||
[ text (error.name ++ ": ") ]
|
||||
, text error.detail
|
||||
]
|
||||
in
|
||||
case message.errors of
|
||||
[] ->
|
||||
text ""
|
||||
|
||||
errors ->
|
||||
div [ class "well well-warn message-warn" ]
|
||||
[ div [] [ h3 [] [ text "MIME problems detected" ] ]
|
||||
, ul [] (List.map row errors)
|
||||
]
|
||||
|
||||
|
||||
messageBody : Message -> Body -> Html Msg
|
||||
messageBody message bodyMode =
|
||||
let
|
||||
bodyModeTab mode label =
|
||||
a
|
||||
[ classList [ ( "active", bodyMode == mode ) ]
|
||||
, Events.onClick (MessageBody mode)
|
||||
, href "#"
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
safeHtml =
|
||||
bodyModeTab SafeHtmlBody "Safe HTML"
|
||||
|
||||
plainText =
|
||||
bodyModeTab TextBody "Plain Text"
|
||||
|
||||
tabs =
|
||||
if message.html == "" then
|
||||
[ plainText ]
|
||||
|
||||
else
|
||||
[ safeHtml, plainText ]
|
||||
in
|
||||
div [ class "tab-panel" ]
|
||||
[ nav [ class "tab-bar" ] tabs
|
||||
, article [ class "message-body" ]
|
||||
[ case bodyMode of
|
||||
SafeHtmlBody ->
|
||||
Html.node "rendered-html" [ property "content" (E.string message.html) ] []
|
||||
|
||||
TextBody ->
|
||||
Html.node "rendered-html" [ property "content" (E.string message.text) ] []
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
attachments : Message -> Html Msg
|
||||
attachments message =
|
||||
if List.isEmpty message.attachments then
|
||||
div [] []
|
||||
|
||||
else
|
||||
table [ class "attachments well" ] (List.map (attachmentRow message) message.attachments)
|
||||
|
||||
|
||||
attachmentRow : Message -> Message.Attachment -> Html Msg
|
||||
attachmentRow message attach =
|
||||
let
|
||||
url =
|
||||
Api.serveUrl
|
||||
[ "mailbox"
|
||||
, message.mailbox
|
||||
, message.id
|
||||
, "attach"
|
||||
, attach.id
|
||||
, attach.fileName
|
||||
]
|
||||
in
|
||||
tr []
|
||||
[ td []
|
||||
[ a [ href url, target "_blank" ] [ text attach.fileName ]
|
||||
, text (" (" ++ attach.contentType ++ ") ")
|
||||
]
|
||||
, td [] [ a [ href url, download attach.fileName, class "button" ] [ text "Download" ] ]
|
||||
]
|
||||
|
||||
|
||||
relativeDate : Model -> Posix -> Html Msg
|
||||
relativeDate model date =
|
||||
Relative.relativeTime model.now date |> text
|
||||
|
||||
|
||||
verboseDate : Time.Zone -> Posix -> Html Msg
|
||||
verboseDate zone date =
|
||||
text <|
|
||||
DF.format
|
||||
[ DF.monthNameFull
|
||||
, DF.text " "
|
||||
, DF.dayOfMonthSuffix
|
||||
, DF.text ", "
|
||||
, DF.yearNumber
|
||||
, DF.text " "
|
||||
, DF.hourNumber
|
||||
, DF.text ":"
|
||||
, DF.minuteFixed
|
||||
, DF.text ":"
|
||||
, DF.secondFixed
|
||||
, DF.text " "
|
||||
, DF.amPmUppercase
|
||||
, DF.text " (Local)"
|
||||
]
|
||||
zone
|
||||
date
|
||||
|
||||
|
||||
|
||||
-- UTILITY
|
||||
|
||||
|
||||
filterMessageList : MessageList -> List MessageHeader
|
||||
filterMessageList list =
|
||||
if list.searchFilter == "" then
|
||||
list.headers
|
||||
|
||||
else
|
||||
let
|
||||
matches header =
|
||||
String.contains list.searchFilter (String.toLower header.subject)
|
||||
|| String.contains list.searchFilter (String.toLower header.from)
|
||||
in
|
||||
List.filter matches list.headers
|
||||
|
||||
|
||||
onKeyUp : (Int -> msg) -> Attribute msg
|
||||
onKeyUp tagger =
|
||||
Events.on "keyup" (D.map tagger Events.keyCode)
|
||||
@@ -0,0 +1,172 @@
|
||||
module Page.Monitor exposing (Model, Msg, init, update, view)
|
||||
|
||||
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.Events as Events
|
||||
import Json.Decode as D
|
||||
import Route
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, connected : Bool
|
||||
, messages : List MessageHeader
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Cmd Msg )
|
||||
init session =
|
||||
( Model session False [], Cmd.none )
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= Connected Bool
|
||||
| MessageReceived D.Value
|
||||
| Clear
|
||||
| OpenMessage MessageHeader
|
||||
| MessageKeyPress MessageHeader Int
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Connected True ->
|
||||
( { model | connected = True, messages = [] }, Cmd.none )
|
||||
|
||||
Connected False ->
|
||||
( { model | connected = False }, Cmd.none )
|
||||
|
||||
MessageReceived value ->
|
||||
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
|
||||
Ok header ->
|
||||
( { model | messages = header :: List.take 500 model.messages }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Err err ->
|
||||
let
|
||||
flash =
|
||||
{ title = "Message decoding failed"
|
||||
, table = [ ( "Error", D.errorToString err ) ]
|
||||
}
|
||||
in
|
||||
( { model | session = Session.showFlash flash model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Clear ->
|
||||
( { model | messages = [] }, Cmd.none )
|
||||
|
||||
OpenMessage header ->
|
||||
openMessage header model
|
||||
|
||||
MessageKeyPress header keyCode ->
|
||||
case keyCode of
|
||||
13 ->
|
||||
openMessage header model
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
|
||||
openMessage header model =
|
||||
( model
|
||||
, Route.pushUrl model.session.key (Route.Message header.mailbox header.id)
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket Monitor"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ h1 [] [ text "Monitor" ]
|
||||
, div [ class "monitor-header" ]
|
||||
[ span [ class "monitor-description" ]
|
||||
[ text "Messages will be listed here shortly after delivery. "
|
||||
, em []
|
||||
[ text
|
||||
(if model.connected then
|
||||
"Connected."
|
||||
|
||||
else
|
||||
"Disconnected!"
|
||||
)
|
||||
]
|
||||
]
|
||||
, span [ class "button-bar monitor-buttons" ]
|
||||
[ button [ Events.onClick Clear ] [ text "Clear" ]
|
||||
]
|
||||
]
|
||||
, node "monitor-messages"
|
||||
[ Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
|
||||
, Events.on "message" (D.map MessageReceived D.value)
|
||||
]
|
||||
[]
|
||||
, table [ class "monitor" ]
|
||||
[ thead []
|
||||
[ th [] [ text "Date" ]
|
||||
, th [ class "desktop" ] [ text "From" ]
|
||||
, th [] [ text "Mailbox" ]
|
||||
, th [] [ text "Subject" ]
|
||||
]
|
||||
, tbody [] (List.map (viewMessage model.session.zone) model.messages)
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewMessage : Time.Zone -> MessageHeader -> Html Msg
|
||||
viewMessage zone message =
|
||||
tr
|
||||
[ tabindex 0
|
||||
, Events.onClick (OpenMessage message)
|
||||
, onKeyUp (MessageKeyPress message)
|
||||
]
|
||||
[ td [] [ shortDate zone message.date ]
|
||||
, td [ class "desktop" ] [ text message.from ]
|
||||
, td [] [ text message.mailbox ]
|
||||
, td [] [ text message.subject ]
|
||||
]
|
||||
|
||||
|
||||
shortDate : Time.Zone -> Posix -> Html Msg
|
||||
shortDate zone date =
|
||||
DF.format
|
||||
[ DF.dayOfMonthFixed
|
||||
, DF.text "-"
|
||||
, DF.monthNameAbbreviated
|
||||
, DF.text " "
|
||||
, DF.hourNumber
|
||||
, DF.text ":"
|
||||
, DF.minuteFixed
|
||||
, DF.text ":"
|
||||
, DF.secondFixed
|
||||
, DF.text " "
|
||||
, DF.amPmUppercase
|
||||
]
|
||||
zone
|
||||
date
|
||||
|> text
|
||||
|
||||
|
||||
onKeyUp : (Int -> msg) -> Attribute msg
|
||||
onKeyUp tagger =
|
||||
Events.on "keyup" (D.map tagger Events.keyCode)
|
||||
@@ -0,0 +1,566 @@
|
||||
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.Session as Session exposing (Session)
|
||||
import DateFormat.Relative as Relative
|
||||
import Filesize
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Sparkline as Spark
|
||||
import Svg.Attributes as SvgAttrib
|
||||
import Task
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ session : Session
|
||||
, now : Posix
|
||||
, config : Maybe ServerConfig
|
||||
, metrics : Maybe Metrics
|
||||
, xCounter : Float
|
||||
, sysMem : Metric
|
||||
, heapSize : Metric
|
||||
, heapUsed : Metric
|
||||
, heapObjects : Metric
|
||||
, goRoutines : Metric
|
||||
, webSockets : Metric
|
||||
, smtpConnOpen : Metric
|
||||
, smtpConnTotal : Metric
|
||||
, smtpReceivedTotal : Metric
|
||||
, smtpErrorsTotal : Metric
|
||||
, smtpWarnsTotal : Metric
|
||||
, retentionDeletesTotal : Metric
|
||||
, retainedCount : Metric
|
||||
, retainedSize : Metric
|
||||
}
|
||||
|
||||
|
||||
type alias Metric =
|
||||
{ label : String
|
||||
, value : Int
|
||||
, formatter : Int -> String
|
||||
, graph : Spark.DataSet -> Html Msg
|
||||
, history : Spark.DataSet
|
||||
, minutes : Int
|
||||
}
|
||||
|
||||
|
||||
init : Session -> ( Model, Cmd Msg )
|
||||
init session =
|
||||
( { session = session
|
||||
, now = Time.millisToPosix 0
|
||||
, config = Nothing
|
||||
, metrics = Nothing
|
||||
, xCounter = 60
|
||||
, sysMem = Metric "System Memory" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapSize = Metric "Heap Size" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapUsed = Metric "Heap Used" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapObjects = Metric "Heap # Objects" 0 fmtInt graphZero initDataSet 10
|
||||
, goRoutines = Metric "Goroutines" 0 fmtInt graphZero initDataSet 10
|
||||
, webSockets = Metric "Open WebSockets" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnOpen = Metric "Open Connections" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnTotal = Metric "Total Connections" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpReceivedTotal = Metric "Messages Received" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpErrorsTotal = Metric "Message Errors" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpWarnsTotal = Metric "Message Warnings" 0 fmtInt graphChange initDataSet 60
|
||||
, retentionDeletesTotal = Metric "Retention Deletes" 0 fmtInt graphChange initDataSet 60
|
||||
, retainedCount = Metric "Stored Messages" 0 fmtInt graphZero initDataSet 60
|
||||
, retainedSize = Metric "Store Size" 0 Filesize.format graphZero initDataSet 60
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Task.perform Tick Time.now
|
||||
, Api.getServerConfig ServerConfigLoaded
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
initDataSet : Spark.DataSet
|
||||
initDataSet =
|
||||
List.range 0 59
|
||||
|> List.map (\x -> ( toFloat x, 0 ))
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Time.every (10 * 1000) Tick
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= MetricsReceived (Result HttpUtil.Error Metrics)
|
||||
| ServerConfigLoaded (Result HttpUtil.Error ServerConfig)
|
||||
| Tick Posix
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
MetricsReceived (Ok metrics) ->
|
||||
( updateMetrics metrics model, Cmd.none )
|
||||
|
||||
MetricsReceived (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ServerConfigLoaded (Ok config) ->
|
||||
( { model | config = Just config }, Cmd.none )
|
||||
|
||||
ServerConfigLoaded (Err err) ->
|
||||
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Tick time ->
|
||||
( { model | now = time }, Api.getServerMetrics MetricsReceived )
|
||||
|
||||
|
||||
{-| Update all metrics in Model; increment xCounter.
|
||||
-}
|
||||
updateMetrics : Metrics -> Model -> Model
|
||||
updateMetrics metrics model =
|
||||
let
|
||||
x =
|
||||
model.xCounter
|
||||
in
|
||||
{ model
|
||||
| metrics = Just metrics
|
||||
, xCounter = x + 1
|
||||
, sysMem = updateLocalMetric model.sysMem x metrics.sysMem
|
||||
, heapSize = updateLocalMetric model.heapSize x metrics.heapSize
|
||||
, heapUsed = updateLocalMetric model.heapUsed x metrics.heapUsed
|
||||
, heapObjects = updateLocalMetric model.heapObjects x metrics.heapObjects
|
||||
, goRoutines = updateLocalMetric model.goRoutines x metrics.goRoutines
|
||||
, webSockets = updateLocalMetric model.webSockets x metrics.webSockets
|
||||
, smtpConnOpen = updateLocalMetric model.smtpConnOpen x metrics.smtpConnOpen
|
||||
, smtpConnTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpConnTotal
|
||||
metrics.smtpConnTotal
|
||||
metrics.smtpConnHist
|
||||
, smtpReceivedTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpReceivedTotal
|
||||
metrics.smtpReceivedTotal
|
||||
metrics.smtpReceivedHist
|
||||
, smtpErrorsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpErrorsTotal
|
||||
metrics.smtpErrorsTotal
|
||||
metrics.smtpErrorsHist
|
||||
, smtpWarnsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpWarnsTotal
|
||||
metrics.smtpWarnsTotal
|
||||
metrics.smtpWarnsHist
|
||||
, retentionDeletesTotal =
|
||||
updateRemoteTotal
|
||||
model.retentionDeletesTotal
|
||||
metrics.retentionDeletesTotal
|
||||
metrics.retentionDeletesHist
|
||||
, retainedCount =
|
||||
updateRemoteMetric
|
||||
model.retainedCount
|
||||
metrics.retainedCount
|
||||
metrics.retainedCountHist
|
||||
, retainedSize =
|
||||
updateRemoteMetric
|
||||
model.retainedSize
|
||||
metrics.retainedSize
|
||||
metrics.retainedSizeHist
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked locally.
|
||||
-}
|
||||
updateLocalMetric : Metric -> Float -> Int -> Metric
|
||||
updateLocalMetric metric x value =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
Maybe.withDefault [] (List.tail metric.history)
|
||||
++ [ ( x, toFloat value ) ]
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server.
|
||||
-}
|
||||
updateRemoteMetric : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteMetric metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server. Sparkline will chart changes to the
|
||||
total instead of its absolute value.
|
||||
-}
|
||||
updateRemoteTotal : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteTotal metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> changeList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
|
||||
view model =
|
||||
{ title = "Inbucket Status"
|
||||
, modal = Nothing
|
||||
, content =
|
||||
[ h1 [] [ text "Status" ]
|
||||
, div [] (configPanel model.config :: metricPanels model)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
configPanel : Maybe ServerConfig -> Html Msg
|
||||
configPanel maybeConfig =
|
||||
let
|
||||
mailboxCap config =
|
||||
case config.storageConfig.mailboxMsgCap of
|
||||
0 ->
|
||||
"Unlimited"
|
||||
|
||||
cap ->
|
||||
String.fromInt cap ++ " messages per mailbox"
|
||||
|
||||
retentionPeriod config =
|
||||
case config.storageConfig.retentionPeriod of
|
||||
"" ->
|
||||
"Forever"
|
||||
|
||||
period ->
|
||||
period
|
||||
in
|
||||
case maybeConfig of
|
||||
Nothing ->
|
||||
text "Loading server config..."
|
||||
|
||||
Just config ->
|
||||
framePanel "Configuration"
|
||||
"fa-cog"
|
||||
[ textEntry "Version" (config.version ++ ", built on " ++ config.buildDate)
|
||||
, 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 "Store Type" config.storageConfig.storeType
|
||||
, textEntry "Message Cap" (mailboxCap config)
|
||||
, textEntry "Retention Period" (retentionPeriod config)
|
||||
]
|
||||
|
||||
|
||||
acceptPolicy config =
|
||||
if config.defaultAccept then
|
||||
"All domains"
|
||||
++ (case config.rejectDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.acceptDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
|
||||
storePolicy config =
|
||||
if config.defaultStore then
|
||||
"All domains"
|
||||
++ (case config.discardDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
else
|
||||
"No domains"
|
||||
++ (case config.storeDomains of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just [] ->
|
||||
""
|
||||
|
||||
Just domains ->
|
||||
", except: " ++ String.join ", " domains
|
||||
)
|
||||
|
||||
|
||||
metricPanels : Model -> List (Html Msg)
|
||||
metricPanels model =
|
||||
case model.metrics of
|
||||
Nothing ->
|
||||
[ text "Loading metrics..." ]
|
||||
|
||||
Just metrics ->
|
||||
[ framePanel "General Metrics"
|
||||
"fa-tachometer-alt"
|
||||
[ textEntry "Uptime" <|
|
||||
"Started "
|
||||
++ Relative.relativeTime model.now metrics.startTime
|
||||
, viewMetric model.sysMem
|
||||
, viewMetric model.heapSize
|
||||
, viewMetric model.heapUsed
|
||||
, viewMetric model.heapObjects
|
||||
, viewMetric model.goRoutines
|
||||
, viewMetric model.webSockets
|
||||
]
|
||||
, framePanel "SMTP Metrics"
|
||||
"fa-envelope"
|
||||
[ viewMetric model.smtpConnOpen
|
||||
, viewMetric model.smtpConnTotal
|
||||
, viewMetric model.smtpReceivedTotal
|
||||
, viewMetric model.smtpErrorsTotal
|
||||
, viewMetric model.smtpWarnsTotal
|
||||
]
|
||||
, framePanel "Storage Metrics"
|
||||
"fa-archive"
|
||||
[ textEntry "Retention Scan" (retentionScan model)
|
||||
, viewMetric model.retentionDeletesTotal
|
||||
, viewMetric model.retainedCount
|
||||
, viewMetric model.retainedSize
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
retentionScan : Model -> String
|
||||
retentionScan model =
|
||||
case ( model.config, model.metrics ) of
|
||||
( Just config, Just metrics ) ->
|
||||
case config.storageConfig.retentionPeriod of
|
||||
"" ->
|
||||
"Disabled"
|
||||
|
||||
_ ->
|
||||
case Time.posixToMillis metrics.scanCompleted of
|
||||
0 ->
|
||||
"Not completed"
|
||||
|
||||
_ ->
|
||||
"Completed " ++ Relative.relativeTime model.now metrics.scanCompleted
|
||||
|
||||
( _, _ ) ->
|
||||
"No data"
|
||||
|
||||
|
||||
textEntry : String -> String -> Html Msg
|
||||
textEntry name value =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text name ]
|
||||
, div [ class "text-value" ] [ text value ]
|
||||
]
|
||||
|
||||
|
||||
viewMetric : Metric -> Html Msg
|
||||
viewMetric metric =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text metric.label ]
|
||||
, div [ class "value" ] [ text (metric.formatter metric.value) ]
|
||||
, div [ class "graph" ]
|
||||
[ metric.graph metric.history
|
||||
, text ("(" ++ String.fromInt metric.minutes ++ "min)")
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
, height = 16
|
||||
, marginLR = 0
|
||||
, marginTB = 0
|
||||
}
|
||||
|
||||
|
||||
areaStyle : Spark.Param a -> Spark.Param a
|
||||
areaStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.fill "rgba(50,100,255,0.3)"
|
||||
, SvgAttrib.stroke "rgba(50,100,255,1.0)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
barStyle : Spark.Param a -> Spark.Param a
|
||||
barStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.fill "rgba(50,200,50,0.7)"
|
||||
]
|
||||
|
||||
|
||||
zeroStyle : Spark.Param a -> Spark.Param a
|
||||
zeroStyle =
|
||||
Spark.Style
|
||||
[ SvgAttrib.stroke "rgba(0,0,0,0.2)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
|
||||
-}
|
||||
graphChange : Spark.DataSet -> Html a
|
||||
graphChange data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
Spark.sparkline graphSize
|
||||
[ Spark.Bar 2.5 data |> barStyle
|
||||
, Spark.ZeroLine |> zeroStyle
|
||||
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
{-| Zero based area graph, for charting absolute values relative to 0.
|
||||
-}
|
||||
graphZero : Spark.DataSet -> Html a
|
||||
graphZero data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
Spark.sparkline graphSize
|
||||
[ Spark.Area data |> areaStyle
|
||||
, Spark.ZeroLine |> zeroStyle
|
||||
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
framePanel : String -> String -> List (Html a) -> Html a
|
||||
framePanel name icon html =
|
||||
let
|
||||
fontIcon cn =
|
||||
i [ class ("fas " ++ cn) ] []
|
||||
in
|
||||
div [ class "metric-panel" ]
|
||||
[ h2 []
|
||||
[ fontIcon icon
|
||||
, text " "
|
||||
, text name
|
||||
]
|
||||
, div [ class "metrics" ] html
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- UTILS --
|
||||
|
||||
|
||||
{-| Compute difference between each Int in numbers.
|
||||
-}
|
||||
changeList : List Int -> List Int
|
||||
changeList numbers =
|
||||
let
|
||||
tail =
|
||||
List.tail numbers |> Maybe.withDefault []
|
||||
in
|
||||
List.map2 (-) tail numbers
|
||||
|
||||
|
||||
{-| Pad the front of a list with 0s to make it at least 60 elements long.
|
||||
-}
|
||||
zeroPadList : List Int -> List Int
|
||||
zeroPadList numbers =
|
||||
let
|
||||
needed =
|
||||
60 - List.length numbers
|
||||
in
|
||||
if needed > 0 then
|
||||
List.repeat needed 0 ++ numbers
|
||||
|
||||
else
|
||||
numbers
|
||||
|
||||
|
||||
{-| Format an Int with thousands separators.
|
||||
-}
|
||||
fmtInt : Int -> String
|
||||
fmtInt n =
|
||||
let
|
||||
-- thousands recursively inserts thousands separators.
|
||||
thousands str =
|
||||
if String.length str <= 3 then
|
||||
str
|
||||
|
||||
else
|
||||
thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str
|
||||
in
|
||||
thousands (String.fromInt n)
|
||||
@@ -0,0 +1,12 @@
|
||||
port module Ports exposing
|
||||
( onSessionChange
|
||||
, storeSession
|
||||
)
|
||||
|
||||
import Json.Encode exposing (Value)
|
||||
|
||||
|
||||
port onSessionChange : (Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
port storeSession : Value -> Cmd msg
|
||||
@@ -0,0 +1,88 @@
|
||||
module Route exposing (Route(..), fromUrl, href, pushUrl, replaceUrl)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
type Route
|
||||
= Unknown String
|
||||
| Home
|
||||
| Mailbox String
|
||||
| Message String String
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
{-| Routes our application handles.
|
||||
-}
|
||||
routes : List (Parser (Route -> a) a)
|
||||
routes =
|
||||
[ map Home top
|
||||
, map Message (s "m" </> string </> string)
|
||||
, map Mailbox (s "m" </> string)
|
||||
, map Monitor (s "monitor")
|
||||
, map Status (s "status")
|
||||
]
|
||||
|
||||
|
||||
{-| Convert route to a URI.
|
||||
-}
|
||||
routeToPath : Route -> String
|
||||
routeToPath page =
|
||||
let
|
||||
pieces =
|
||||
case page of
|
||||
Unknown _ ->
|
||||
[]
|
||||
|
||||
Home ->
|
||||
[]
|
||||
|
||||
Mailbox name ->
|
||||
[ "m", name ]
|
||||
|
||||
Message mailbox id ->
|
||||
[ "m", mailbox, id ]
|
||||
|
||||
Monitor ->
|
||||
[ "monitor" ]
|
||||
|
||||
Status ->
|
||||
[ "status" ]
|
||||
in
|
||||
Builder.absolute pieces []
|
||||
|
||||
|
||||
|
||||
-- PUBLIC HELPERS
|
||||
|
||||
|
||||
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
|
||||
|
||||
Just route ->
|
||||
route
|
||||
@@ -0,0 +1,75 @@
|
||||
import './main.css'
|
||||
import './mailbox.css'
|
||||
import './navbar.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.css'
|
||||
import '@webcomponents/webcomponentsjs/webcomponents-bundle'
|
||||
import { Elm } from './Main.elm'
|
||||
import './monitorMessages'
|
||||
import './renderedHtml'
|
||||
|
||||
// Initial configuration from Inbucket server to Elm App.
|
||||
var flags = {
|
||||
"app-config": appConfig(),
|
||||
"session": sessionObject(),
|
||||
}
|
||||
|
||||
// App startup.
|
||||
var app = Elm.Main.init({
|
||||
node: document.getElementById('root'),
|
||||
flags: flags,
|
||||
})
|
||||
|
||||
// Session storage.
|
||||
app.ports.storeSession.subscribe(function (session) {
|
||||
localStorage.session = JSON.stringify(session)
|
||||
})
|
||||
|
||||
window.addEventListener("storage", function (event) {
|
||||
if (event.storageArea === localStorage && event.key === "session") {
|
||||
app.ports.onSessionChange.send(sessionObject())
|
||||
}
|
||||
}, false)
|
||||
|
||||
// Decode the JSON value of the app-config cookie, then delete it.
|
||||
function appConfig() {
|
||||
var name = "app-config"
|
||||
var c = getCookie(name)
|
||||
if (c) {
|
||||
deleteCookie(name)
|
||||
return JSON.parse(decodeURIComponent(c))
|
||||
}
|
||||
console.warn("Inbucket " + name + " cookie not found, running with defaults.")
|
||||
return {
|
||||
"monitor-visible": true,
|
||||
}
|
||||
}
|
||||
|
||||
// Grab peristent session data out of local storage.
|
||||
function sessionObject() {
|
||||
try {
|
||||
var s = localStorage.session
|
||||
if (s) {
|
||||
return JSON.parse(s)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getCookie(cookieName) {
|
||||
var name = cookieName + "="
|
||||
var cookies = decodeURIComponent(document.cookie).split(';')
|
||||
for (var i=0; i<cookies.length; i++) {
|
||||
var cookie = cookies[i].trim()
|
||||
if (cookie.indexOf(name) == 0) {
|
||||
return cookie.substring(name.length, cookie.length)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function deleteCookie(cookieName) {
|
||||
document.cookie = cookieName +
|
||||
"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/** MAILBOX */
|
||||
|
||||
.mailbox {
|
||||
display: grid;
|
||||
grid:
|
||||
"ctrl" auto
|
||||
"list" auto
|
||||
"mesg" auto / 1fr;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.message-list-controls {
|
||||
display: none;
|
||||
grid-area: ctrl;
|
||||
}
|
||||
|
||||
.list-active .message-list-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-list-controls button,
|
||||
.message-list-controls input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-list-controls button {
|
||||
color: var(--low-color);
|
||||
margin-left: 1px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.message-list-controls button:disabled {
|
||||
color: var(--disabled-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.message-list-controls input {
|
||||
flex: 1 1 auto;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: none;
|
||||
grid-area: list;
|
||||
}
|
||||
|
||||
.list-active .message-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: none;
|
||||
grid-area: mesg;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-active .message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-close {
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1000px) {
|
||||
.mailbox {
|
||||
grid-gap: 1px 20px;
|
||||
grid:
|
||||
"ctrl mesg" auto
|
||||
"list mesg" 1fr / minmax(200px, 300px) minmax(650px, 1000px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: block;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.message-list-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-close {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list-entry {
|
||||
border-color: var(--border-color);
|
||||
border-width: 1px;
|
||||
border-style: none solid solid solid;
|
||||
cursor: pointer;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.message-list-entry.selected {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
|
||||
.message-list-entry:first-child {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.message-list-entry .subject {
|
||||
color: var(--high-color);
|
||||
}
|
||||
|
||||
.message-list-entry.unseen .subject {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-list-entry .from,
|
||||
.message-list-entry .date {
|
||||
color: var(--low-color);
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message-header dt {
|
||||
color: var(--low-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-header dd {
|
||||
color: var(--low-color);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1000px) {
|
||||
.message-header {
|
||||
display: grid;
|
||||
grid-template: auto / 5em 1fr;
|
||||
}
|
||||
|
||||
.message-header dt {
|
||||
grid-column: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-header dd {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.message-warn li {
|
||||
margin-left: 20px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.message-warn-severe {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
nav.tab-bar {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
|
||||
nav.tab-bar a {
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: block;
|
||||
margin-bottom: -1px;
|
||||
margin-right: 2px;
|
||||
padding: 8px 15px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.tab-bar a.active {
|
||||
color: var(--low-color);
|
||||
border-color: var(--border-color) var(--border-color) var(--bg-color) var(--border-color);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
nav.tab-bar a:focus,
|
||||
nav.tab-bar a:hover {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
|
||||
nav.tab-bar a.active:focus,
|
||||
nav.tab-bar a.active:hover {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
width: 100%;
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
/** GLOBAL */
|
||||
|
||||
:root {
|
||||
--bg-color: #fff;
|
||||
--primary-color: #333;
|
||||
--low-color: #666;
|
||||
--disabled-color: #ddd;
|
||||
--high-color: #337ab7;
|
||||
--border-color: #ddd;
|
||||
--placeholder-color: #9f9f9f;
|
||||
--selected-color: #eee;
|
||||
}
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center, button,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: var(--placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #337ab7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
body, button, input, table {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.43;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/** SHARED */
|
||||
|
||||
a.button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
margin: 4px;
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.well {
|
||||
--light: #f5f5f5;
|
||||
--dark: #e8e8e8;
|
||||
background-color: var(--light);
|
||||
background-image: linear-gradient(to bottom, var(--dark) 0, var(--light) 100%);
|
||||
border: 1px solid var(--dark);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
padding: 6px 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.well a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.well-error {
|
||||
--light: #f58080;
|
||||
--dark: #e86060;
|
||||
}
|
||||
|
||||
.well-error a {
|
||||
color: #a00000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.well-warn {
|
||||
--light: #fff8cf;
|
||||
--dark: #fff899;
|
||||
}
|
||||
|
||||
/** APP */
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
grid-gap: 20px;
|
||||
grid-template:
|
||||
"lpad head rpad" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / minmax(20px, auto) 1fr minmax(20px, auto);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
.app {
|
||||
grid-template:
|
||||
"head head head" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / 1px 1fr 1px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
grid-area: head;
|
||||
}
|
||||
|
||||
.page {
|
||||
grid-area: page;
|
||||
/* Explicit minimums required for sizing/scrolling in nested flex+grids. */
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--selected-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
grid-area: foot;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flash-table {
|
||||
border-left: 4px solid rgba(0,0,0,0.2);
|
||||
overflow: auto;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.flash-table th {
|
||||
padding-right: 5px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
width: 700px;
|
||||
max-width: 95%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/** BUTTONS */
|
||||
|
||||
.button-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button-bar button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.button-bar *:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.button-bar button.danger {
|
||||
background-color: #d9534f;
|
||||
background-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
|
||||
}
|
||||
|
||||
.button-bar button.light {
|
||||
background-color: #eee;
|
||||
background-image: linear-gradient(to bottom, #f0f0f0 0, #e0e0e0 100%);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1000px) {
|
||||
.button-bar button {
|
||||
width: 8em;
|
||||
}
|
||||
}
|
||||
|
||||
/** STATUS */
|
||||
|
||||
.metric-panel {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.metric-panel h2 {
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0, #e8e8e8 100%);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.metric-panel .metrics {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.metric-panel .metric {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
flex-basis: 15em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
flex-basis: 15em;
|
||||
}
|
||||
|
||||
.metric .text-value {
|
||||
flex-basis: 40em;
|
||||
}
|
||||
|
||||
.metric .graph {
|
||||
flex-basis: 25em;
|
||||
}
|
||||
|
||||
/** MONITOR **/
|
||||
|
||||
.monitor-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.monitor-description {
|
||||
display: inline-block;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.monitor {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.monitor th {
|
||||
background: var(--bg-color);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
text-align: left;
|
||||
padding: 5px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.monitor td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.monitor tr:hover {
|
||||
background-color: var(--selected-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// monitor-messages connects to the Inbucket backend via WebSocket to monitor
|
||||
// incoming messages.
|
||||
customElements.define(
|
||||
'monitor-messages',
|
||||
class MonitorMessages extends HTMLElement {
|
||||
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
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const self = this
|
||||
self._socket = new WebSocket(self._url)
|
||||
var ws = self._socket
|
||||
ws.addEventListener('open', function (e) {
|
||||
self.dispatchEvent(new CustomEvent('connected', { detail: true }))
|
||||
})
|
||||
ws.addEventListener('close', function (e) {
|
||||
self.dispatchEvent(new CustomEvent('connected', { detail: false }))
|
||||
})
|
||||
ws.addEventListener('message', function (e) {
|
||||
self.dispatchEvent(new CustomEvent('message', {
|
||||
detail: JSON.parse(e.data),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
var ws = this._socket
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
/** NAV BAR */
|
||||
|
||||
:root {
|
||||
--navbar-color: #9d9d9d;
|
||||
--navbar-bg: #222;
|
||||
--navbar-image: linear-gradient(to bottom, #3c3c3c 0, #222 100%);
|
||||
--navbar-height: 50px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--navbar-bg);
|
||||
background-image: var(--navbar-image);
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
min-height: var(--navbar-height);
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.main-nav.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
color: var(--navbar-color);
|
||||
font-size: 24px;
|
||||
padding: 0 6px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.navbar a,
|
||||
.navbar-dropdown {
|
||||
color: var(--navbar-color);
|
||||
display: block;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* This takes precendence over .navbar a above */
|
||||
.navbar-brand a {
|
||||
display: inline-block;
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar li {
|
||||
color: var(--navbar-color);
|
||||
}
|
||||
|
||||
li.navbar-active > *:first-child {
|
||||
background-color: #080808;
|
||||
}
|
||||
|
||||
li.navbar-active a,
|
||||
li.navbar-active span,
|
||||
.navbar a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a {
|
||||
color: var(--navbar-color) !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a:hover {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.navbar-mailbox {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.navbar-mailbox input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1000px) {
|
||||
.main-nav {
|
||||
height: var(--navbar-height);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
align-items: center;
|
||||
background: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
flex-grow: 2;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-nav.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-bg {
|
||||
background-color: var(--navbar-bg);
|
||||
background-image: var(--navbar-image);
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 1;
|
||||
height: var(--navbar-height);
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
margin-left: -15px;
|
||||
}
|
||||
|
||||
.navbar-mailbox {
|
||||
order: 1;
|
||||
padding: 9px 0 8px 0 !important;
|
||||
}
|
||||
|
||||
.navbar-dropdown-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.navbar-dropdown {
|
||||
padding: 15px 19px 15px 25px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--navbar-color);
|
||||
display: inline;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
display: none;
|
||||
min-width: 160px;
|
||||
position: absolute;
|
||||
text-shadow: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.navbar-dropdown-container[aria-expanded="true"] .navbar-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a {
|
||||
color: var(--primary-color) !important;
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a:hover {
|
||||
color: var(--primary-color) !important;
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// This element allows Inbucket to draw server rendered HTML, aka HTML email.
|
||||
// https://leveljournal.com/server-rendered-html-in-elm
|
||||
|
||||
customElements.define(
|
||||
"rendered-html",
|
||||
class RenderedHtml extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this._content = ""
|
||||
}
|
||||
|
||||
set content(value) {
|
||||
if (this._content === value) {
|
||||
return
|
||||
}
|
||||
this._content = value
|
||||
this.innerHTML = value
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this._content
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "inbucket-bootstrap",
|
||||
"homepage": "http://www.inbucket.org/",
|
||||
"authors": [
|
||||
"James Hillyerd <james@hillyerd.com>"
|
||||
],
|
||||
"description": "Bootstrap theme for Inbucket",
|
||||
"main": [],
|
||||
"moduleType": [
|
||||
"globals"
|
||||
],
|
||||
"license": "MIT",
|
||||
"ignore": [
|
||||
"**/.*",
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
"test",
|
||||
"tests"
|
||||
],
|
||||
"dependencies": {
|
||||
"bootstrap": "3.3",
|
||||
"jquery": "2",
|
||||
"jquery-color": "^2.1.2",
|
||||
"jquery-sparkline": "^2.1.3",
|
||||
"clipboard": "^1.5.9",
|
||||
"jquery-load-template": "^1.5.6",
|
||||
"moment": "^2.11.2"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user