1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-09-15 08:08:54 +00:00

Merge branch 'release/3.0.0-beta1'

This commit is contained in:
James Hillyerd
2019-03-14 21:26:15 -07:00
758 changed files with 13272 additions and 282872 deletions
+15 -1
View File
@@ -25,7 +25,10 @@ _testmain.go
*.swp
*.swo
# our binaries
# Desktop Services Store on macOS
.DS_Store
# Inbucket binaries
/client
/client.exe
/inbucket
@@ -35,3 +38,14 @@ _testmain.go
/cmd/client/client.exe
/cmd/inbucket/inbucket
/cmd/inbucket/inbucket.exe
# Elm UI
# elm-package generated files
/ui/elm-stuff
/ui/tests/elm-stuff
# elm-repl generated files
repl-temp-*
# Distribution
/ui/dist/
# Dependency directories
/ui/node_modules
+12 -3
View File
@@ -2,7 +2,7 @@ project_name: inbucket
release:
github:
owner: jhillyerd
owner: inbucket
name: inbucket
name_template: '{{.Tag}}'
@@ -12,8 +12,14 @@ brew:
email: goreleaser@carlosbecker.com
install: bin.install ""
before:
hooks:
- go mod download
builds:
- binary: inbucket
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -26,6 +32,8 @@ builds:
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: inbucket-client
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -51,7 +59,8 @@ archive:
- README*
- CHANGELOG*
- etc/**/*
- ui/**/*
- ui/dist/**/*
- ui/greeting.html
nfpm:
vendor: inbucket.org
@@ -63,7 +72,7 @@ nfpm:
- deb
- rpm
files:
"ui/**/*": "/usr/local/share/inbucket/ui"
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
config_files:
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
"ui/greeting.html": "/etc/inbucket/greeting.html"
+35 -18
View File
@@ -1,26 +1,43 @@
language: go
sudo: false
addons:
apt:
packages:
- rpm
env:
global:
- GO111MODULE=on
- DEPLOY_WITH_MAJOR="1.11"
before_script:
- go get golang.org/x/lint/golint
- make deps
language: go
go:
- "1.10.x"
- "1.11.x"
install:
- "go get golang.org/x/lint/golint"
- "make deps"
deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true
jobs:
include:
- go: "1.11.x"
- go: "master"
- language: elm
elm: "0.19.0"
install:
- "cd ui"
- "npm ci"
script:
- "elm-format --validate ."
- "npm run build"
- stage: deploy
go: "1.11.x"
before_install:
- "nvm install 10.13.0"
install:
- "cd ui"
- "npm ci"
- "npm run build"
- "cd .."
script: "curl -sL https://git.io/goreleaser | bash"
addons:
apt:
packages:
- rpm
stages:
- test
- name: deploy
if: tag IS present
+31 -17
View File
@@ -4,6 +4,19 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [v3.0.0-beta1]
### Added
- `posix-millis` field to REST message and header responses for easier date
parsing.
### Changed
- Rewrote the user interface from scratch, it's now an Elm powered single page
application.
- Moved the Inbucket repository to its own GitHub organization.
- Update to enmime v0.5.0
## v2.1.0
No change from beta1.
@@ -102,7 +115,7 @@ No change from beta1.
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
provides a more natural API
- Powerful command line REST
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
@@ -117,9 +130,9 @@ No change from beta1.
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
- Attachment list to [GET message
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
@@ -182,19 +195,20 @@ No change from beta1.
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
## Release Checklist
@@ -207,7 +221,7 @@ No change from beta1.
3. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
+7 -2
View File
@@ -2,7 +2,7 @@
# Build
FROM golang:1.11-alpine3.8 as builder
RUN apk add --no-cache --virtual .build-deps git make
RUN apk add --no-cache --virtual .build-deps git make npm
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
@@ -10,14 +10,18 @@ RUN make clean deps
RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm i
RUN npm run build
# Run in minimal image
FROM alpine:3.8
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin
COPY --from=builder /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY ui ui
COPY etc/docker/defaults/start-inbucket.sh /
# Configuration
@@ -26,6 +30,7 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
ENV INBUCKET_POP3_TIMEOUT 30s
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR=ui
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
+13 -13
View File
@@ -1,6 +1,6 @@
Inbucket
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
[![Build Status](https://travis-ci.org/inbucket/inbucket.png?branch=master)][Build Status]
Inbucket is an email testing service; it will accept messages for any email
address and make them available via web, REST and POP3. Once compiled,
@@ -8,7 +8,7 @@ Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
A Go client for the REST API is available in
`github.com/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
Read more at the [Inbucket Website]
@@ -25,7 +25,7 @@ to contribute code to the project check out [CONTRIBUTING.md].
## Homebrew Tap
(currently broken, being tracked in [issue
#68](https://github.com/jhillyerd/inbucket/issues/68))
#68](https://github.com/inbucket/inbucket/issues/68))
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
@@ -37,12 +37,12 @@ You will need a functioning [Go installation][Google Go] for this to work.
Grab the Inbucket source code and compile the daemon:
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
go get -v github.com/inbucket/inbucket/cmd/inbucket
Edit etc/inbucket.conf and tailor to your environment. It should work on most
Unix and OS X machines as is. Launch the daemon:
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
$GOPATH/bin/inbucket $GOPATH/src/github.com/inbucket/inbucket/etc/inbucket.conf
By default the SMTP server will be listening on localhost port 2500 and
the web interface will be available at [localhost:9000](http://localhost:9000/).
@@ -56,15 +56,15 @@ The Inbucket website has a more complete guide to
Inbucket is written in [Google Go]
Inbucket is open source software released under the MIT License. The latest
version can be found at https://github.com/jhillyerd/inbucket
version can be found at https://github.com/inbucket/inbucket
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
[Go API docs]: https://godoc.org/github.com/inbucket/inbucket/pkg/rest/client
[Build Status]: https://travis-ci.org/inbucket/inbucket
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
[From Source]: http://www.inbucket.org/installation/from-source.html
[Google Go]: http://golang.org/
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
[Inbucket Website]: http://www.inbucket.org/
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
type listCmd struct {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"time"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
type matchCmd struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
type mboxCmd struct {
+16 -18
View File
@@ -14,18 +14,18 @@ import (
"syscall"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/server/pop3"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/file"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/server/pop3"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -40,10 +40,8 @@ var (
func init() {
// Server uptime for status page.
startTime := time.Now()
expvar.Publish("uptime", expvar.Func(func() interface{} {
return time.Since(startTime) / time.Second
}))
startTime := expvar.NewInt("startMillis")
startTime.Set(time.Now().UnixNano() / 1000000)
// Goroutine count for status page.
expvar.Publish("goroutines", expvar.Func(func() interface{} {
@@ -124,9 +122,9 @@ func main() {
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub)
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router)
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
+2 -38
View File
@@ -28,11 +28,8 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_UIDIR ui User interface dir
INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
@@ -298,7 +295,7 @@ doesn't contain the `ui` directory at startup.
Inbucket will load templates from the `templates` sub-directory, and serve
static assets from the `static` sub-directory.
- Default: `ui`
- Default: `ui/dist`
- Values: Operating system specific path syntax
### Greeting HTML File
@@ -311,39 +308,6 @@ Inbucket installation, as well as link to REST documentation, etc.
- Default: `ui/greeting.html`
### Template Caching
`INBUCKET_WEB_TEMPLATECACHE`
Tells Inbucket to cache parsed template files. This should be left as default
unless you are a developer working on the Inbucket web interface.
- Default: `true`
- Values: `true` or `false`
### Mailbox Prompt
`INBUCKET_WEB_MAILBOXPROMPT`
Text prompt displayed to the right of the mailbox name input field in the web
interface. Can be used to nudge your users into typing just the mailbox name
instead of an entire email address.
Set to an empty string to hide the prompt.
- Default: `@inbucket`
### Cookie Authentication Key
`INBUCKET_WEB_COOKIEAUTHKEY`
Inbucket stores session information in an encrypted browser cookie. Unless
specified, Inbucket generates a random key at startup. The only notable data
stored in a user session is the list of recently accessed mailboxes.
- Default: None
- Value: Text string, no particular format required
### Monitor Visible
`INBUCKET_WEB_MONITORVISIBLE`
+17 -2
View File
@@ -3,16 +3,31 @@
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
#export INBUCKET_SMTP_DEFAULTSTORE="false"
export INBUCKET_SMTP_STOREDOMAINS="important.local"
export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false"
export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2
echo "Run this script from the inbucket root directory after running make" >&2
echo "Run this script from the inbucket root directory after running make." >&2
exit 1
fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'npm run build' from the 'ui' directory." >&2
exit 1
fi
+2
View File
@@ -1,3 +1,5 @@
<h1>Welcome to Inbucket</h1>
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
+31
View File
@@ -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
+3
View File
@@ -56,3 +56,6 @@ swaks $* --data outlook.raw
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw
-10
View File
@@ -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
+3 -4
View File
@@ -1,14 +1,13 @@
module github.com/jhillyerd/inbucket
module github.com/inbucket/inbucket
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.6.2
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.1.3
github.com/gorilla/websocket v1.4.0
github.com/jhillyerd/enmime v0.2.1
github.com/jhillyerd/enmime v0.5.0
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.3.0
github.com/microcosm-cc/bluemonday v1.0.1
+10 -6
View File
@@ -1,5 +1,9 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@@ -8,16 +12,12 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
github.com/jhillyerd/enmime v0.5.0 h1:0U3mdCFsFsdQcuztie8g+eQgZ2GhtVCun8AJArOqdzc=
github.com/jhillyerd/enmime v0.5.0/go.mod h1:/bb6lwXIWgsVnrO4uuLg8rBVqidJYeG9I34d/WfWurg=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
@@ -28,10 +28,14 @@ github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVm
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
+2 -5
View File
@@ -8,7 +8,7 @@ import (
"text/tabwriter"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
@@ -93,11 +93,8 @@ type POP3 struct {
// Web contains the HTTP server configuration.
type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
CookieAuthKey string `desc:"Session cipher key (text)"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
+5 -5
View File
@@ -7,11 +7,11 @@ import (
"strings"
"time"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
broadcast := msghub.Message{
Mailbox: to.Mailbox,
ID: id,
From: delivery.From().String(),
From: stringutil.StringAddress(delivery.From()),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"net/textproto"
"time"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Metadata holds information about a message, but not the content.
+6 -6
View File
@@ -6,8 +6,8 @@ import (
"net/mail"
"strings"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/stringutil"
)
// Addressing handles email address policy.
@@ -159,16 +159,16 @@ func ValidateDomainPart(domain string) bool {
// domain part is optional and not validated.
func parseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", fmt.Errorf("Empty address")
return "", "", fmt.Errorf("empty address")
}
if len(address) > 320 {
return "", "", fmt.Errorf("Address exceeds 320 characters")
return "", "", fmt.Errorf("address exceeds 320 characters")
}
if address[0] == '@' {
return "", "", fmt.Errorf("Address cannot start with @ symbol")
return "", "", fmt.Errorf("address cannot start with @ symbol")
}
if address[0] == '.' {
return "", "", fmt.Errorf("Address cannot start with a period")
return "", "", fmt.Errorf("address cannot start with a period")
}
// Loop over address parsing out local part.
buf := new(bytes.Buffer)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/policy"
)
func TestShouldAcceptDomain(t *testing.T) {
+23 -21
View File
@@ -10,10 +10,10 @@ import (
"encoding/json"
"strconv"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
}
}
return web.RenderJSON(w, jmessages)
@@ -77,15 +78,16 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
}
return web.RenderJSON(w,
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Body: &model.JSONMessageBodyV1{
Text: msg.Text(),
HTML: msg.HTML(),
+5 -2
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/test"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/test"
)
const (
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[0]/subject", "subject 1")
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
decodedNumberEquals(t, result, "[0]/size", 0)
decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/mailbox", "good")
@@ -120,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[1]/subject", "subject 2")
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
decodedNumberEquals(t, result, "[1]/size", 0)
decodedBoolEquals(t, result, "[1]/seen", false)
@@ -221,6 +223,7 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
decodedStringEquals(t, result, "subject", "subject 1")
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
decodedNumberEquals(t, result, "size", 0)
decodedBoolEquals(t, result, "seen", true)
decodedStringEquals(t, result, "body/text", "This is some text")
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/url"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
func TestClientV1ListMailbox(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
// Example demonstrates basic usage for the Inbucket REST client.
+3 -3
View File
@@ -33,7 +33,7 @@ func (c *restClient) do(method, uri string, body []byte) (*http.Response, error)
}
req, err := http.NewRequest(method, url.String(), r)
if err != nil {
return nil, err
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
@@ -56,7 +56,7 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
@@ -77,5 +77,5 @@ func (c *restClient) doJSONBody(method string, uri string, body []byte, v interf
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}
+10 -8
View File
@@ -6,14 +6,15 @@ import (
// JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"`
+1 -1
View File
@@ -1,7 +1,7 @@
package rest
import "github.com/gorilla/mux"
import "github.com/jhillyerd/inbucket/pkg/server/web"
import "github.com/inbucket/inbucket/pkg/server/web"
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
+11 -10
View File
@@ -5,9 +5,9 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/rs/zerolog/log"
)
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
return
}
header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
// Write failed
+9 -7
View File
@@ -9,10 +9,10 @@ import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/server/web"
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
},
}
shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
return buf
}
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(float64); ok {
got, ok := val.(float64)
if ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
path, val, val, int64(got), want, int64(want))
}
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
+5 -5
View File
@@ -12,11 +12,11 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
type scriptStep struct {
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/metric"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog/log"
)
+5
View File
@@ -0,0 +1,5 @@
package web
type jsonAppConfig struct {
MonitorVisible bool `json:"monitor-visible"`
}
+4 -17
View File
@@ -5,17 +5,15 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
)
// Context is passed into every request handler function
// TODO remove redundant web config
type Context struct {
Vars map[string]string
Session *sessions.Session
MsgHub *msghub.Hub
Manager message.Manager
RootConfig *config.Root
@@ -48,24 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
// NewContext returns a Context for the given HTTP Request
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
if err != nil {
if sess == nil {
// No session, must fail
return nil, err
}
// The session cookie was probably signed by an old key, ignore it
// gorilla created an empty session for us
err = nil
}
ctx := &Context{
Vars: vars,
Session: sess,
MsgHub: msgHub,
Manager: manager,
RootConfig: rootConfig,
WebConfig: rootConfig.Web,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
return ctx, nil
}
+84
View File
@@ -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)
})
}
+2 -44
View File
@@ -3,62 +3,20 @@ package web
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"address": stringutil.StringAddress,
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"stringsJoin": strings.Join,
"textToHtml": TextToHTML,
}
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
// otherwise Mon Jan 2, 2006
func FriendlyTime(t time.Time) template.HTML {
ty, tm, td := t.Date()
ny, nm, nd := time.Now().Date()
if (ty == ny) && (tm == nm) && (td == nd) {
return template.HTML(t.Format("03:04:05 PM"))
}
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// Reverse routing function (shared with templates)
func Reverse(name string, things ...interface{}) string {
// Convert the things to strings
strs := make([]string, len(things))
for i, th := range things {
strs[i] = fmt.Sprint(th)
}
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.Error().Str("module", "web").Str("name", name).Err(err).
Msg("Failed to reverse route")
return "/ROUTE-ERROR"
}
return u.Path
}
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func TextToHTML(text string) template.HTML {
func TextToHTML(text string) string {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text))
return replacer.Replace(text)
}
// WrapURL wraps a <a href> tag around the provided URL
+47 -22
View File
@@ -1,30 +1,55 @@
package web
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;"))
// Check for linebreaks
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
TextToHTML("http://google.com/"),
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
assert.Equal(t,
TextToHTML("http://a.com/?q=a&n=v"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
testCases := []struct {
input, want string
}{
{
input: "html",
want: "html",
},
// Check it escapes.
{
input: "<html>",
want: "&lt;html&gt;",
},
// Check for linebreaks.
{
input: "line\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\r\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\rbreak",
want: "line<br/>\nbreak",
},
// Check URL detection.
{
input: "http://google.com/",
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
},
{
input: "http://a.com/?q=a&n=v",
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;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&amp;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
View File
@@ -3,30 +3,22 @@ package web
import (
"context"
"encoding/json"
"expvar"
"net"
"net/http"
"net/http/pprof"
"net/url"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/rs/zerolog/log"
)
// Handler is a function type that handles an HTTP request in Inbucket
type Handler func(http.ResponseWriter, *http.Request, *Context) error
const (
staticDir = "static"
templateDir = "templates"
)
var (
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
@@ -39,7 +31,6 @@ var (
rootConfig *config.Root
server *http.Server
listener net.Listener
sessionStore sessions.Store
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
@@ -51,7 +42,7 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method
// Initialize sets up things for unit tests or the Start() method.
func Initialize(
conf *config.Root,
shutdownChan chan bool,
@@ -61,16 +52,13 @@ func Initialize(
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
// NewContext() will use this DataStore for the web handlers.
msgHub = mh
manager = mm
// Content Paths
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
// Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(staticPath))))
Router.Handle("/debug/vars", expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
@@ -82,23 +70,32 @@ func Initialize(
Msg("Go pprof tools installed to /debug/pprof")
}
// Session cookie setup
if conf.Web.CookieAuthKey == "" {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key")
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
} else {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
}
// Static paths.
Router.PathPrefix("/static").Handler(
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path("/favicon.png").Handler(
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
Router.Path("/").Handler(spaHandler)
Router.Path("/monitor").Handler(spaHandler)
Router.Path("/status").Handler(spaHandler)
Router.PathPrefix("/m/").Handler(spaHandler)
// Error handlers.
Router.NotFoundHandler = noMatchHandler(
http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path")
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: Router,
Handler: requestLoggingWrapper(Router),
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
@@ -132,6 +129,22 @@ func Start(ctx context.Context) {
}
}
func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{
MonitorVisible: webConfig.MonitorVisible,
}
b, err := json.Marshal(o)
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("Failed to convert app-config to JSON")
}
return &http.Cookie{
Name: "app-config",
Value: url.PathEscape(string(b)),
Path: "/",
}
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
@@ -148,29 +161,6 @@ func serve(ctx context.Context) {
}
}
// ServeHTTP builds the context and passes onto the real handler
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context
ctx, err := NewContext(req)
if err != nil {
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
err = h(w, req, ctx)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {
-84
View File
@@ -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
}
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
+4 -4
View File
@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"sync"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package storage_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
func TestHashLock(t *testing.T) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
// Message is a memory store message.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv"
"sync"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
)
// Store implements an in-memory message store.
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
// TestSuite runs storage package test suite on file store.
+5 -23
View File
@@ -3,17 +3,15 @@ package storage
import (
"container/list"
"expvar"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/metric"
"github.com/rs/zerolog/log"
)
var (
retentionScanCompleted = time.Now()
retentionScanCompletedMu sync.RWMutex
scanCompletedMillis = new(expvar.Int)
// History counters
expRetentionDeletesTotal = new(expvar.Int)
@@ -34,7 +32,7 @@ var (
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("ScanCompletedMillis", scanCompletedMillis)
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
return err
}
// Update metrics
setRetentionScanCompleted(time.Now())
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
expRetainedCurrent.Set(int64(retained))
expRetainedSize.Set(storeSize)
return nil
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
<-rs.retentionShutdown
}
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
func TestDoRetentionScan(t *testing.T) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/config"
)
var (
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/mail"
"testing"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/stringutil"
)
func TestHashMailboxName(t *testing.T) {
+17 -15
View File
@@ -11,18 +11,20 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/rest"
"github.com/inbucket/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/server/smtp"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/jhillyerd/goldiff"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
const (
@@ -35,8 +37,7 @@ func TestSuite(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_ = stopServer
// defer stopServer()
defer stopServer()
testCases := []struct {
name string
@@ -152,6 +153,7 @@ func formatMessage(m *client.Message) []byte {
func startServer() (func(), error) {
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
storage.Constructors["memory"] = mem.New
os.Clearenv()
conf, err := config.Process()
@@ -169,9 +171,9 @@ func startServer() (func(), error) {
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub)
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router)
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
+4 -4
View File
@@ -3,10 +3,10 @@ package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
)
// ManagerStub is a test stub for message.Manager
+1 -1
View File
@@ -3,7 +3,7 @@ package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreStub stubs storage.Store for testing.
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreFactory returns a new store for the test suite.
+58 -175
View File
@@ -1,144 +1,82 @@
package webui
import (
"errors"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui/sanitize"
"github.com/rs/zerolog/log"
)
// MailboxIndex renders the index page for a particular mailbox
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Form values must be validated manually
name := req.FormValue("name")
selected := req.FormValue("id")
if len(name) == 0 {
ctx.Session.AddFlash("Account name is required", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err = ctx.Manager.MailboxForAddress(name)
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Remember this mailbox was visited
RememberMailbox(ctx, name)
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
"selected": selected,
})
}
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxLink handles pretty links to a particular message. Renders a redirect
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxList renders a list of messages in a mailbox. Renders a partial
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
// Render partial template
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"messages": messages,
})
}
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
// MailboxMessage outputs a particular message as JSON for the UI.
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
if err != nil && err != storage.ErrNotExist {
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if msg == nil {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
attachments := make([]*jsonAttachment, 0)
for i, part := range msg.Attachments() {
attachments = append(attachments, &jsonAttachment{
ID: strconv.Itoa(i),
FileName: part.FileName,
ContentType: part.ContentType,
})
}
body := template.HTML(web.TextToHTML(msg.Text()))
htmlAvailable := msg.HTML() != ""
var htmlBody template.HTML
if htmlAvailable {
mimeErrors := make([]*jsonMIMEError, 0)
for _, e := range msg.MIMEErrors() {
mimeErrors = append(mimeErrors, &jsonMIMEError{
Name: e.Name,
Detail: e.Detail,
Severe: e.Severe,
})
}
// Sanitize HTML body.
htmlBody := ""
if msg.HTML() != "" {
if str, err := sanitize.HTML(msg.HTML()); err == nil {
htmlBody = template.HTML(str)
htmlBody = str
} else {
// Soft failure, render empty tab.
htmlBody = "Inbucket HTML sanitizer failed."
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
Msg("HTML sanitizer failed")
}
}
// Render partial template
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": msg,
"body": body,
"htmlAvailable": htmlAvailable,
"htmlBody": htmlBody,
"mimeErrors": msg.MIMEErrors(),
"attachments": msg.Attachments(),
})
return web.RenderJSON(w,
&jsonMessage{
Mailbox: name,
ID: msg.ID,
From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Text: web.TextToHTML(msg.Text()),
HTML: htmlBody,
Attachments: attachments,
Errors: mimeErrors,
})
}
// MailboxHTML displays the HTML content of a message. Renders a partial
@@ -158,14 +96,10 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (er
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
// Render partial template
// Render HTML
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": msg,
"body": template.HTML(msg.HTML()),
})
_, err = w.Write([]byte(msg.HTML()))
return err
}
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
@@ -191,66 +125,18 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
return err
}
// MailboxDownloadAttach sends the attachment to the client; disposition:
// attachment, type: application/octet-stream
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
_, err = w.Write(msg.Attachments()[num].Content)
return err
}
// MailboxViewAttach sends the attachment to the client for online viewing
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return err
}
id := ctx.Vars["id"]
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return err
}
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
@@ -262,10 +148,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Contex
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return errors.New("requested attachment number does not exist")
}
// Output attachment
part := msg.Attachments()[num]
+34
View File
@@ -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"`
}
-35
View File
@@ -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
}
+35 -81
View File
@@ -2,98 +2,52 @@ package webui
import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/server/web"
)
// RootIndex serves the Inbucket landing page
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// RootGreeting serves the Inbucket greeting.
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
if err != nil {
return fmt.Errorf("Failed to load greeting: %v", err)
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"greeting": template.HTML(string(greeting)),
})
w.Header().Set("Content-Type", "text/html")
_, err = w.Write(greeting)
return err
}
// RootMonitor serves the Inbucket monitor page
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
if !ctx.RootConfig.Web.MonitorVisible {
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
})
}
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
if !ctx.RootConfig.Web.MonitorVisible {
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
})
}
// RootStatus serves the Inbucket status page
// RootStatus renders portions of the server configuration as JSON.
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
root := ctx.RootConfig
retPeriod := ""
if root.Storage.RetentionPeriod > 0 {
retPeriod = root.Storage.RetentionPeriod.String()
}
// Render template
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"version": config.Version,
"buildDate": config.BuildDate,
"smtpListener": ctx.RootConfig.SMTP.Addr,
"pop3Listener": ctx.RootConfig.POP3.Addr,
"webListener": ctx.RootConfig.Web.Addr,
"smtpConfig": ctx.RootConfig.SMTP,
"storageConfig": ctx.RootConfig.Storage,
})
return web.RenderJSON(w,
&jsonServerConfig{
Version: config.Version,
BuildDate: config.BuildDate,
POP3Listener: root.POP3.Addr,
WebListener: root.Web.Addr,
SMTPConfig: jsonSMTPConfig{
Addr: root.SMTP.Addr,
DefaultAccept: root.SMTP.DefaultAccept,
AcceptDomains: root.SMTP.AcceptDomains,
RejectDomains: root.SMTP.RejectDomains,
DefaultStore: root.SMTP.DefaultStore,
StoreDomains: root.SMTP.StoreDomains,
DiscardDomains: root.SMTP.DiscardDomains,
},
StorageConfig: jsonStorageConfig{
MailboxMsgCap: root.Storage.MailboxMsgCap,
StoreType: root.Storage.Type,
RetentionPeriod: retPeriod,
},
})
}
+7 -21
View File
@@ -3,35 +3,21 @@ package webui
import (
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/server/web"
)
// SetupRoutes populates routes for the webui into the provided Router
// SetupRoutes populates routes for the webui into the provided Router.
func SetupRoutes(r *mux.Router) {
r.Path("/").Handler(
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
r.Path("/monitor").Handler(
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
r.Path("/monitor/{name}").Handler(
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
r.Path("/greeting").Handler(
web.Handler(RootGreeting)).Name("RootGreeting").Methods("GET")
r.Path("/status").Handler(
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
r.Path("/link/{name}/{id}").Handler(
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
r.Path("/mailbox").Handler(
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
r.Path("/mailbox/{name}").Handler(
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
r.Path("/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
r.Path("/mailbox/{name}/{id}/html").Handler(
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
web.Handler(MailboxHTML)).Name("MailboxHTML").Methods("GET")
r.Path("/mailbox/{name}/{id}/source").Handler(
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
r.Path("/{name}").Handler(
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
}
+1 -1
View File
@@ -3,7 +3,7 @@ package sanitize_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/inbucket/inbucket/pkg/webui/sanitize"
)
// TestHTMLPlainStrings test plain text passthrough
+26
View File
@@ -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"`
}
+15
View File
@@ -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
];
}
+44
View File
@@ -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
View File
@@ -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": {}
}
}
+2
View File
@@ -1,3 +1,5 @@
<h1>Welcome to Inbucket</h1>
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
+8396
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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

+19
View File
@@ -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>
+15
View File
@@ -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
View File
@@ -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 ])
+20
View File
@@ -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
+11
View File
@@ -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
+69
View File
@@ -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
+31
View File
@@ -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
+70
View File
@@ -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))
+107
View File
@@ -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
+122
View File
@@ -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 )
]
+133
View File
@@ -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)
+269
View File
@@ -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
View File
@@ -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
}
+63
View File
@@ -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)
]
[]
]
}
+774
View File
@@ -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)
+172
View File
@@ -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)
+566
View File
@@ -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)
+12
View File
@@ -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
+88
View File
@@ -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
+75
View File
@@ -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=/;"
}
+206
View File
@@ -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
View File
@@ -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;
}
+38
View File
@@ -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()
}
}
}
)
+177
View File
@@ -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);
}
}
+24
View File
@@ -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
}
}
)
-29
View File
@@ -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