mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-09-16 16:48:54 +00:00
Merge branch 'release/3.0.0-beta1'
This commit is contained in:
+15
-1
@@ -25,7 +25,10 @@ _testmain.go
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# our binaries
|
# Desktop Services Store on macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Inbucket binaries
|
||||||
/client
|
/client
|
||||||
/client.exe
|
/client.exe
|
||||||
/inbucket
|
/inbucket
|
||||||
@@ -35,3 +38,14 @@ _testmain.go
|
|||||||
/cmd/client/client.exe
|
/cmd/client/client.exe
|
||||||
/cmd/inbucket/inbucket
|
/cmd/inbucket/inbucket
|
||||||
/cmd/inbucket/inbucket.exe
|
/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:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: jhillyerd
|
owner: inbucket
|
||||||
name: inbucket
|
name: inbucket
|
||||||
name_template: '{{.Tag}}'
|
name_template: '{{.Tag}}'
|
||||||
|
|
||||||
@@ -12,8 +12,14 @@ brew:
|
|||||||
email: goreleaser@carlosbecker.com
|
email: goreleaser@carlosbecker.com
|
||||||
install: bin.install ""
|
install: bin.install ""
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: inbucket
|
- binary: inbucket
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -26,6 +32,8 @@ builds:
|
|||||||
main: ./cmd/inbucket
|
main: ./cmd/inbucket
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
- binary: inbucket-client
|
- binary: inbucket-client
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -51,7 +59,8 @@ archive:
|
|||||||
- README*
|
- README*
|
||||||
- CHANGELOG*
|
- CHANGELOG*
|
||||||
- etc/**/*
|
- etc/**/*
|
||||||
- ui/**/*
|
- ui/dist/**/*
|
||||||
|
- ui/greeting.html
|
||||||
|
|
||||||
nfpm:
|
nfpm:
|
||||||
vendor: inbucket.org
|
vendor: inbucket.org
|
||||||
@@ -63,7 +72,7 @@ nfpm:
|
|||||||
- deb
|
- deb
|
||||||
- rpm
|
- rpm
|
||||||
files:
|
files:
|
||||||
"ui/**/*": "/usr/local/share/inbucket/ui"
|
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
||||||
config_files:
|
config_files:
|
||||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||||
|
|||||||
+35
-18
@@ -1,26 +1,43 @@
|
|||||||
language: go
|
|
||||||
sudo: false
|
sudo: false
|
||||||
|
|
||||||
addons:
|
|
||||||
apt:
|
|
||||||
packages:
|
|
||||||
- rpm
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
global:
|
global:
|
||||||
- GO111MODULE=on
|
- GO111MODULE=on
|
||||||
- DEPLOY_WITH_MAJOR="1.11"
|
|
||||||
|
|
||||||
before_script:
|
language: go
|
||||||
- go get golang.org/x/lint/golint
|
|
||||||
- make deps
|
|
||||||
|
|
||||||
go:
|
install:
|
||||||
- "1.10.x"
|
- "go get golang.org/x/lint/golint"
|
||||||
- "1.11.x"
|
- "make deps"
|
||||||
|
|
||||||
deploy:
|
jobs:
|
||||||
provider: script
|
include:
|
||||||
script: etc/travis-deploy.sh
|
- go: "1.11.x"
|
||||||
on:
|
- go: "master"
|
||||||
tags: true
|
- 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.
|
All notable changes to this project will be documented in this file.
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [v3.0.0-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
|
## v2.1.0
|
||||||
|
|
||||||
No change from beta1.
|
No change from beta1.
|
||||||
@@ -102,7 +115,7 @@ No change from beta1.
|
|||||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||||
provides a more natural API
|
provides a more natural API
|
||||||
- Powerful command line REST
|
- 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
|
- Allow use of `latest` as a message ID in REST calls
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -117,9 +130,9 @@ No change from beta1.
|
|||||||
### Added
|
### Added
|
||||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||||
- Attachment list to [GET message
|
- 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
|
- [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
|
- Monitor feature: lists messages as they arrive, regardless of their
|
||||||
destination mailbox
|
destination mailbox
|
||||||
- Make `@inbucket` mailbox prompt configurable
|
- 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
|
- Add Link button to messages, allows for directing another person to a
|
||||||
specific message.
|
specific message.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||||
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
|
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
[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
|
## Release Checklist
|
||||||
@@ -207,7 +221,7 @@ No change from beta1.
|
|||||||
3. Run tests
|
3. Run tests
|
||||||
4. Test cross-compile: `goreleaser --snapshot`
|
4. Test cross-compile: `goreleaser --snapshot`
|
||||||
5. Commit changes and merge release: `git flow release finish`
|
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
|
complete
|
||||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM golang:1.11-alpine3.8 as builder
|
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
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV CGO_ENABLED 0
|
ENV CGO_ENABLED 0
|
||||||
@@ -10,14 +10,18 @@ RUN make clean deps
|
|||||||
RUN go build -o inbucket \
|
RUN go build -o inbucket \
|
||||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||||
-v ./cmd/inbucket
|
-v ./cmd/inbucket
|
||||||
|
WORKDIR /build/ui
|
||||||
|
RUN rm -rf dist elm-stuff node_modules
|
||||||
|
RUN npm i
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Run in minimal image
|
# Run in minimal image
|
||||||
FROM alpine:3.8
|
FROM alpine:3.8
|
||||||
WORKDIR /opt/inbucket
|
WORKDIR /opt/inbucket
|
||||||
RUN mkdir bin defaults ui
|
RUN mkdir bin defaults ui
|
||||||
COPY --from=builder /build/inbucket bin
|
COPY --from=builder /build/inbucket bin
|
||||||
|
COPY --from=builder /build/ui/dist ui
|
||||||
COPY etc/docker/defaults/greeting.html defaults
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
COPY ui ui
|
|
||||||
COPY etc/docker/defaults/start-inbucket.sh /
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -26,6 +30,7 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
|||||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||||
|
ENV INBUCKET_WEB_UIDIR=ui
|
||||||
ENV INBUCKET_STORAGE_TYPE file
|
ENV INBUCKET_STORAGE_TYPE file
|
||||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
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
|
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,
|
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).
|
are all built in).
|
||||||
|
|
||||||
A Go client for the REST API is available 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]
|
Read more at the [Inbucket Website]
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ to contribute code to the project check out [CONTRIBUTING.md].
|
|||||||
## Homebrew Tap
|
## Homebrew Tap
|
||||||
|
|
||||||
(currently broken, being tracked in [issue
|
(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],
|
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||||
see the `README.md` there for installation instructions.
|
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:
|
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
|
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
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
|
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/).
|
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 written in [Google Go]
|
||||||
|
|
||||||
Inbucket is open source software released under the MIT License. The latest
|
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
|
[Go API docs]: https://godoc.org/github.com/inbucket/inbucket/pkg/rest/client
|
||||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
|
||||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
|
||||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||||
[Google Go]: http://golang.org/
|
[Google Go]: http://golang.org/
|
||||||
[Homebrew]: http://brew.sh/
|
[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/
|
[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"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listCmd struct {
|
type listCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type matchCmd struct {
|
type matchCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mboxCmd struct {
|
type mboxCmd struct {
|
||||||
|
|||||||
+16
-18
@@ -14,18 +14,18 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
"github.com/inbucket/inbucket/pkg/rest"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/pop3"
|
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage/file"
|
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
"github.com/inbucket/inbucket/pkg/webui"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -40,10 +40,8 @@ var (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Server uptime for status page.
|
// Server uptime for status page.
|
||||||
startTime := time.Now()
|
startTime := expvar.NewInt("startMillis")
|
||||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||||
return time.Since(startTime) / time.Second
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Goroutine count for status page.
|
// Goroutine count for status page.
|
||||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||||
@@ -124,9 +122,9 @@ func main() {
|
|||||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||||
retentionScanner.Start()
|
retentionScanner.Start()
|
||||||
// Start HTTP server.
|
// Start HTTP server.
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
webui.SetupRoutes(web.Router)
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
go web.Start(rootCtx)
|
go web.Start(rootCtx)
|
||||||
// Start POP3 server.
|
// Start POP3 server.
|
||||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||||
|
|||||||
+2
-38
@@ -28,11 +28,8 @@ variables it supports:
|
|||||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||||
INBUCKET_WEB_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_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_MONITORVISIBLE true Show monitor tab in UI?
|
||||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||||
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
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
|
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||||
static assets from the `static` sub-directory.
|
static assets from the `static` sub-directory.
|
||||||
|
|
||||||
- Default: `ui`
|
- Default: `ui/dist`
|
||||||
- Values: Operating system specific path syntax
|
- Values: Operating system specific path syntax
|
||||||
|
|
||||||
### Greeting HTML File
|
### Greeting HTML File
|
||||||
@@ -311,39 +308,6 @@ Inbucket installation, as well as link to REST documentation, etc.
|
|||||||
|
|
||||||
- Default: `ui/greeting.html`
|
- 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
|
### Monitor Visible
|
||||||
|
|
||||||
`INBUCKET_WEB_MONITORVISIBLE`
|
`INBUCKET_WEB_MONITORVISIBLE`
|
||||||
|
|||||||
+17
-2
@@ -3,16 +3,31 @@
|
|||||||
# description: Developer friendly Inbucket configuration
|
# description: Developer friendly Inbucket configuration
|
||||||
|
|
||||||
export INBUCKET_LOGLEVEL="debug"
|
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_DISCARDDOMAINS="bitbucket.local"
|
||||||
|
#export INBUCKET_SMTP_DEFAULTSTORE="false"
|
||||||
|
export INBUCKET_SMTP_STOREDOMAINS="important.local"
|
||||||
export INBUCKET_WEB_TEMPLATECACHE="false"
|
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||||
|
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||||
|
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||||
export INBUCKET_STORAGE_TYPE="file"
|
export INBUCKET_STORAGE_TYPE="file"
|
||||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||||
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||||
|
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
|
||||||
|
|
||||||
if ! test -x ./inbucket; then
|
if ! test -x ./inbucket; then
|
||||||
echo "$PWD/inbucket not found/executable!" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<h1>Welcome to Inbucket</h1>
|
||||||
|
|
||||||
<p>Inbucket is an email testing service; it will accept email for any email
|
<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>
|
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
|
# Non-mime responsive HTML test
|
||||||
swaks $* --data nonmime-html-responsive.raw
|
swaks $* --data nonmime-html-responsive.raw
|
||||||
swaks $* --data nonmime-html-inlined.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 (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
|
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/css v1.0.0
|
||||||
github.com/gorilla/mux v1.6.2
|
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/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/jhillyerd/goldiff v0.1.0
|
||||||
github.com/kelseyhightower/envconfig v1.3.0
|
github.com/kelseyhightower/envconfig v1.3.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.1
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||||
|
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
|
github.com/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 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
|
||||||
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
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=
|
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/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 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
|
||||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
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 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
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 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||||
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
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.5.0 h1:0U3mdCFsFsdQcuztie8g+eQgZ2GhtVCun8AJArOqdzc=
|
||||||
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
|
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 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||||
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||||
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
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/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 h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
|
||||||
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
|
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/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 h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/kelseyhightower/envconfig"
|
"github.com/kelseyhightower/envconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,11 +93,8 @@ type POP3 struct {
|
|||||||
// Web contains the HTTP server configuration.
|
// Web contains the HTTP server configuration.
|
||||||
type Web struct {
|
type Web struct {
|
||||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||||
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"`
|
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?"`
|
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||||
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/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"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
|
|||||||
broadcast := msghub.Message{
|
broadcast := msghub.Message{
|
||||||
Mailbox: to.Mailbox,
|
Mailbox: to.Mailbox,
|
||||||
ID: id,
|
ID: id,
|
||||||
From: delivery.From().String(),
|
From: stringutil.StringAddress(delivery.From()),
|
||||||
To: stringutil.StringAddressList(delivery.To()),
|
To: stringutil.StringAddressList(delivery.To()),
|
||||||
Subject: delivery.Subject(),
|
Subject: delivery.Subject(),
|
||||||
Date: delivery.Date(),
|
Date: delivery.Date(),
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/textproto"
|
"net/textproto"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata holds information about a message, but not the content.
|
// Metadata holds information about a message, but not the content.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Addressing handles email address policy.
|
// Addressing handles email address policy.
|
||||||
@@ -159,16 +159,16 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
// domain part is optional and not validated.
|
// domain part is optional and not validated.
|
||||||
func parseEmailAddress(address string) (local string, domain string, err error) {
|
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
if address == "" {
|
if address == "" {
|
||||||
return "", "", fmt.Errorf("Empty address")
|
return "", "", fmt.Errorf("empty address")
|
||||||
}
|
}
|
||||||
if len(address) > 320 {
|
if len(address) > 320 {
|
||||||
return "", "", fmt.Errorf("Address exceeds 320 characters")
|
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||||
}
|
}
|
||||||
if address[0] == '@' {
|
if address[0] == '@' {
|
||||||
return "", "", fmt.Errorf("Address cannot start with @ symbol")
|
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
||||||
}
|
}
|
||||||
if address[0] == '.' {
|
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.
|
// Loop over address parsing out local part.
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldAcceptDomain(t *testing.T) {
|
func TestShouldAcceptDomain(t *testing.T) {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailboxListV1 renders a list of messages in a mailbox
|
// 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))
|
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||||
Mailbox: name,
|
Mailbox: name,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: stringutil.StringAddress(msg.From),
|
From: stringutil.StringAddress(msg.From),
|
||||||
To: stringutil.StringAddressList(msg.To),
|
To: stringutil.StringAddressList(msg.To),
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
Seen: msg.Seen,
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return web.RenderJSON(w, jmessages)
|
return web.RenderJSON(w, jmessages)
|
||||||
@@ -77,15 +78,16 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
}
|
}
|
||||||
return web.RenderJSON(w,
|
return web.RenderJSON(w,
|
||||||
&model.JSONMessageV1{
|
&model.JSONMessageV1{
|
||||||
Mailbox: name,
|
Mailbox: name,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: stringutil.StringAddress(msg.From),
|
From: stringutil.StringAddress(msg.From),
|
||||||
To: stringutil.StringAddressList(msg.To),
|
To: stringutil.StringAddressList(msg.To),
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
Seen: msg.Seen,
|
Size: msg.Size,
|
||||||
Header: msg.Header(),
|
Seen: msg.Seen,
|
||||||
|
Header: msg.Header(),
|
||||||
Body: &model.JSONMessageBodyV1{
|
Body: &model.JSONMessageBodyV1{
|
||||||
Text: msg.Text(),
|
Text: msg.Text(),
|
||||||
HTML: msg.HTML(),
|
HTML: msg.HTML(),
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||||
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
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)
|
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||||
decodedBoolEquals(t, result, "[0]/seen", false)
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
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]/to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||||
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
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)
|
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||||
decodedBoolEquals(t, result, "[1]/seen", false)
|
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, "to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "subject", "subject 1")
|
decodedStringEquals(t, result, "subject", "subject 1")
|
||||||
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||||
decodedNumberEquals(t, result, "size", 0)
|
decodedNumberEquals(t, result, "size", 0)
|
||||||
decodedBoolEquals(t, result, "seen", true)
|
decodedBoolEquals(t, result, "seen", true)
|
||||||
decodedStringEquals(t, result, "body/text", "This is some text")
|
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client accesses the Inbucket REST API v1
|
// Client accesses the Inbucket REST API v1
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientV1ListMailbox(t *testing.T) {
|
func TestClientV1ListMailbox(t *testing.T) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"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.
|
// 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)
|
req, err := http.NewRequest(method, url.String(), r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||||
}
|
}
|
||||||
return c.client.Do(req)
|
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 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.
|
// 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 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
|
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||||
type JSONMessageHeaderV1 struct {
|
type JSONMessageHeaderV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Size int64 `json:"size"`
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
Seen bool `json:"seen"`
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||||
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
|
|||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Seen bool `json:"seen"`
|
Seen bool `json:"seen"`
|
||||||
Body *JSONMessageBodyV1 `json:"body"`
|
Body *JSONMessageBodyV1 `json:"body"`
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import "github.com/gorilla/mux"
|
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
|
// SetupRoutes populates the routes for the REST interface
|
||||||
func SetupRoutes(r *mux.Router) {
|
func SetupRoutes(r *mux.Router) {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
header := &model.JSONMessageHeaderV1{
|
header := &model.JSONMessageHeaderV1{
|
||||||
Mailbox: msg.Mailbox,
|
Mailbox: msg.Mailbox,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: msg.From,
|
From: msg.From,
|
||||||
To: msg.To,
|
To: msg.To,
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
}
|
}
|
||||||
if conn.WriteJSON(header) != nil {
|
if conn.WriteJSON(header) != nil {
|
||||||
// Write failed
|
// Write failed
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||||
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
|
||||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
|
|||||||
t.Errorf("JSON result%s", msg)
|
t.Errorf("JSON result%s", msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got, ok := val.(float64); ok {
|
got, ok := val.(float64)
|
||||||
|
if ok {
|
||||||
if got == want {
|
if got == want {
|
||||||
return
|
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) {
|
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/rs/zerolog/log"
|
"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"
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Context is passed into every request handler function
|
// Context is passed into every request handler function
|
||||||
// TODO remove redundant web config
|
// TODO remove redundant web config
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
Session *sessions.Session
|
|
||||||
MsgHub *msghub.Hub
|
MsgHub *msghub.Hub
|
||||||
Manager message.Manager
|
Manager message.Manager
|
||||||
RootConfig *config.Root
|
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
|
// NewContext returns a Context for the given HTTP Request
|
||||||
func NewContext(req *http.Request) (*Context, error) {
|
func NewContext(req *http.Request) (*Context, error) {
|
||||||
vars := mux.Vars(req)
|
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{
|
ctx := &Context{
|
||||||
Vars: vars,
|
Vars: vars,
|
||||||
Session: sess,
|
|
||||||
MsgHub: msgHub,
|
MsgHub: msgHub,
|
||||||
Manager: manager,
|
Manager: manager,
|
||||||
RootConfig: rootConfig,
|
RootConfig: rootConfig,
|
||||||
WebConfig: rootConfig.Web,
|
WebConfig: rootConfig.Web,
|
||||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"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
|
// 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`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
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
|
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||||
// HTML display
|
// HTML display
|
||||||
func TextToHTML(text string) template.HTML {
|
func TextToHTML(text string) string {
|
||||||
text = html.EscapeString(text)
|
text = html.EscapeString(text)
|
||||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
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
|
// WrapURL wraps a <a href> tag around the provided URL
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTextToHtml(t *testing.T) {
|
func TestTextToHtml(t *testing.T) {
|
||||||
// Identity
|
testCases := []struct {
|
||||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
input, want string
|
||||||
|
}{
|
||||||
// Check it escapes
|
{
|
||||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
input: "html",
|
||||||
|
want: "html",
|
||||||
// Check for linebreaks
|
},
|
||||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
// Check it escapes.
|
||||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
{
|
||||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
input: "<html>",
|
||||||
}
|
want: "<html>",
|
||||||
|
},
|
||||||
func TestURLDetection(t *testing.T) {
|
// Check for linebreaks.
|
||||||
assert.Equal(t,
|
{
|
||||||
TextToHTML("http://google.com/"),
|
input: "line\nbreak",
|
||||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
want: "line<br/>\nbreak",
|
||||||
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>"))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"expvar"
|
"expvar"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
|
||||||
"github.com/rs/zerolog/log"
|
"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 (
|
var (
|
||||||
// msgHub holds a reference to the message pub/sub system
|
// msgHub holds a reference to the message pub/sub system
|
||||||
msgHub *msghub.Hub
|
msgHub *msghub.Hub
|
||||||
@@ -39,7 +31,6 @@ var (
|
|||||||
rootConfig *config.Root
|
rootConfig *config.Root
|
||||||
server *http.Server
|
server *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
sessionStore sessions.Store
|
|
||||||
globalShutdown chan bool
|
globalShutdown chan bool
|
||||||
|
|
||||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||||
@@ -51,7 +42,7 @@ func init() {
|
|||||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
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(
|
func Initialize(
|
||||||
conf *config.Root,
|
conf *config.Root,
|
||||||
shutdownChan chan bool,
|
shutdownChan chan bool,
|
||||||
@@ -61,16 +52,13 @@ func Initialize(
|
|||||||
rootConfig = conf
|
rootConfig = conf
|
||||||
globalShutdown = shutdownChan
|
globalShutdown = shutdownChan
|
||||||
|
|
||||||
// NewContext() will use this DataStore for the web handlers
|
// NewContext() will use this DataStore for the web handlers.
|
||||||
msgHub = mh
|
msgHub = mh
|
||||||
manager = mm
|
manager = mm
|
||||||
|
|
||||||
// Content Paths
|
// Dynamic paths.
|
||||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||||
Msg("Web UI content mapped")
|
Msg("Web UI content mapped")
|
||||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
|
||||||
http.FileServer(http.Dir(staticPath))))
|
|
||||||
Router.Handle("/debug/vars", expvar.Handler())
|
Router.Handle("/debug/vars", expvar.Handler())
|
||||||
if conf.Web.PProf {
|
if conf.Web.PProf {
|
||||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
@@ -82,23 +70,32 @@ func Initialize(
|
|||||||
Msg("Go pprof tools installed to /debug/pprof")
|
Msg("Go pprof tools installed to /debug/pprof")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session cookie setup
|
// Static paths.
|
||||||
if conf.Web.CookieAuthKey == "" {
|
Router.PathPrefix("/static").Handler(
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").
|
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||||
Msg("Generating random cookie.auth.key")
|
Router.Path("/favicon.png").Handler(
|
||||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||||
} else {
|
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").
|
// SPA managed paths.
|
||||||
Msg("Using configured cookie.auth.key")
|
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||||
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
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
|
// Start begins listening for HTTP requests
|
||||||
func Start(ctx context.Context) {
|
func Start(ctx context.Context) {
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: rootConfig.Web.Addr,
|
Addr: rootConfig.Web.Addr,
|
||||||
Handler: Router,
|
Handler: requestLoggingWrapper(Router),
|
||||||
ReadTimeout: 60 * time.Second,
|
ReadTimeout: 60 * time.Second,
|
||||||
WriteTimeout: 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
|
// serve begins serving HTTP requests
|
||||||
func serve(ctx context.Context) {
|
func serve(ctx context.Context) {
|
||||||
// server.Serve blocks until we close the listener
|
// 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() {
|
func emergencyShutdown() {
|
||||||
// Shutdown Inbucket
|
// Shutdown Inbucket
|
||||||
select {
|
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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package storage_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashLock(t *testing.T) {
|
func TestHashLock(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message is a memory store message.
|
// Message is a memory store message.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store implements an in-memory message store.
|
// Store implements an in-memory message store.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSuite runs storage package test suite on file store.
|
// TestSuite runs storage package test suite on file store.
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"expvar"
|
"expvar"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
retentionScanCompleted = time.Now()
|
scanCompletedMillis = new(expvar.Int)
|
||||||
retentionScanCompletedMu sync.RWMutex
|
|
||||||
|
|
||||||
// History counters
|
// History counters
|
||||||
expRetentionDeletesTotal = new(expvar.Int)
|
expRetentionDeletesTotal = new(expvar.Int)
|
||||||
@@ -34,7 +32,7 @@ var (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rm := expvar.NewMap("retention")
|
rm := expvar.NewMap("retention")
|
||||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
rm.Set("ScanCompletedMillis", scanCompletedMillis)
|
||||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||||
rm.Set("Period", expRetentionPeriod)
|
rm.Set("Period", expRetentionPeriod)
|
||||||
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Update metrics
|
// Update metrics
|
||||||
setRetentionScanCompleted(time.Now())
|
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||||
expRetainedCurrent.Set(int64(retained))
|
expRetainedCurrent.Set(int64(retained))
|
||||||
expRetainedSize.Set(storeSize)
|
expRetainedSize.Set(storeSize)
|
||||||
return nil
|
return nil
|
||||||
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
|
|||||||
<-rs.retentionShutdown
|
<-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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDoRetentionScan(t *testing.T) {
|
func TestDoRetentionScan(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashMailboxName(t *testing.T) {
|
func TestHashMailboxName(t *testing.T) {
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/goldiff"
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/rs/zerolog"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/rs/zerolog/log"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -35,8 +37,7 @@ func TestSuite(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_ = stopServer
|
defer stopServer()
|
||||||
// defer stopServer()
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -152,6 +153,7 @@ func formatMessage(m *client.Message) []byte {
|
|||||||
|
|
||||||
func startServer() (func(), error) {
|
func startServer() (func(), error) {
|
||||||
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
// 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
|
storage.Constructors["memory"] = mem.New
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
conf, err := config.Process()
|
conf, err := config.Process()
|
||||||
@@ -169,9 +171,9 @@ func startServer() (func(), error) {
|
|||||||
addrPolicy := &policy.Addressing{Config: conf}
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||||
// Start HTTP server.
|
// Start HTTP server.
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
webui.SetupRoutes(web.Router)
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
go web.Start(rootCtx)
|
go web.Start(rootCtx)
|
||||||
// Start SMTP server.
|
// Start SMTP server.
|
||||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||||
|
|||||||
+4
-4
@@ -3,10 +3,10 @@ package test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManagerStub is a test stub for message.Manager
|
// ManagerStub is a test stub for message.Manager
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreStub stubs storage.Store for testing.
|
// StoreStub stubs storage.Store for testing.
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreFactory returns a new store for the test suite.
|
// StoreFactory returns a new store for the test suite.
|
||||||
|
|||||||
+58
-175
@@ -1,144 +1,82 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailboxIndex renders the index page for a particular mailbox
|
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func MailboxMessage(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
|
|
||||||
id := ctx.Vars["id"]
|
id := ctx.Vars["id"]
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
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)
|
http.NotFound(w, req)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
attachments := make([]*jsonAttachment, 0)
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
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() != ""
|
mimeErrors := make([]*jsonMIMEError, 0)
|
||||||
var htmlBody template.HTML
|
for _, e := range msg.MIMEErrors() {
|
||||||
if htmlAvailable {
|
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 {
|
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||||
htmlBody = template.HTML(str)
|
htmlBody = str
|
||||||
} else {
|
} else {
|
||||||
// Soft failure, render empty tab.
|
htmlBody = "Inbucket HTML sanitizer failed."
|
||||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||||
Msg("HTML sanitizer failed")
|
Msg("HTML sanitizer failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render partial template
|
|
||||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
return web.RenderJSON(w,
|
||||||
"ctx": ctx,
|
&jsonMessage{
|
||||||
"name": name,
|
Mailbox: name,
|
||||||
"message": msg,
|
ID: msg.ID,
|
||||||
"body": body,
|
From: stringutil.StringAddress(msg.From),
|
||||||
"htmlAvailable": htmlAvailable,
|
To: stringutil.StringAddressList(msg.To),
|
||||||
"htmlBody": htmlBody,
|
Subject: msg.Subject,
|
||||||
"mimeErrors": msg.MIMEErrors(),
|
Date: msg.Date,
|
||||||
"attachments": msg.Attachments(),
|
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
|
// 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
|
// This doesn't indicate empty, likely an IO error
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
}
|
}
|
||||||
// Render partial template
|
// Render HTML
|
||||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||||
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
|
_, err = w.Write([]byte(msg.HTML()))
|
||||||
"ctx": ctx,
|
return err
|
||||||
"name": name,
|
|
||||||
"message": msg,
|
|
||||||
"body": template.HTML(msg.HTML()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
|
// 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
|
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
|
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
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
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
return err
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
id := ctx.Vars["id"]
|
id := ctx.Vars["id"]
|
||||||
numStr := ctx.Vars["num"]
|
numStr := ctx.Vars["num"]
|
||||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
return err
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
if err == storage.ErrNotExist {
|
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)
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
}
|
}
|
||||||
if int(num) >= len(msg.Attachments()) {
|
if int(num) >= len(msg.Attachments()) {
|
||||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
return errors.New("requested attachment number does not exist")
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
// Output attachment
|
// Output attachment
|
||||||
part := msg.Attachments()[num]
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RootIndex serves the Inbucket landing page
|
// RootGreeting serves the Inbucket greeting.
|
||||||
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to load greeting: %v", err)
|
return fmt.Errorf("Failed to load greeting: %v", err)
|
||||||
}
|
}
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
_, err = w.Write(greeting)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
"greeting": template.HTML(string(greeting)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootMonitor serves the Inbucket monitor page
|
// RootStatus renders portions of the server configuration as JSON.
|
||||||
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
|
|
||||||
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Get flash messages, save session
|
root := ctx.RootConfig
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
retPeriod := ""
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
if root.Storage.RetentionPeriod > 0 {
|
||||||
return err
|
retPeriod = root.Storage.RetentionPeriod.String()
|
||||||
}
|
}
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
|
return web.RenderJSON(w,
|
||||||
"ctx": ctx,
|
&jsonServerConfig{
|
||||||
"errorFlash": errorFlash,
|
Version: config.Version,
|
||||||
"version": config.Version,
|
BuildDate: config.BuildDate,
|
||||||
"buildDate": config.BuildDate,
|
POP3Listener: root.POP3.Addr,
|
||||||
"smtpListener": ctx.RootConfig.SMTP.Addr,
|
WebListener: root.Web.Addr,
|
||||||
"pop3Listener": ctx.RootConfig.POP3.Addr,
|
SMTPConfig: jsonSMTPConfig{
|
||||||
"webListener": ctx.RootConfig.Web.Addr,
|
Addr: root.SMTP.Addr,
|
||||||
"smtpConfig": ctx.RootConfig.SMTP,
|
DefaultAccept: root.SMTP.DefaultAccept,
|
||||||
"storageConfig": ctx.RootConfig.Storage,
|
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 (
|
import (
|
||||||
"github.com/gorilla/mux"
|
"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) {
|
func SetupRoutes(r *mux.Router) {
|
||||||
r.Path("/").Handler(
|
r.Path("/greeting").Handler(
|
||||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
web.Handler(RootGreeting)).Name("RootGreeting").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("/status").Handler(
|
r.Path("/status").Handler(
|
||||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
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(
|
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(
|
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(
|
r.Path("/mailbox/{name}/{id}/source").Handler(
|
||||||
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
||||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
|
||||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
|
||||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
|
||||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHTMLPlainStrings test plain text passthrough
|
// 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
|
<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>
|
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