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

Merge elm 0.19 upgrade, closes #125

This commit is contained in:
James Hillyerd
2018-11-18 19:47:31 -08:00
44 changed files with 9061 additions and 647 deletions
+6
View File
@@ -4,6 +4,12 @@ Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
### Added
- `posix-millis` field to REST message and header responses for easier date
parsing.
## [v2.1.0-beta1] ## [v2.1.0-beta1]
+1 -1
View File
@@ -6,7 +6,7 @@ export INBUCKET_LOGLEVEL="debug"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local" export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
export INBUCKET_WEB_TEMPLATECACHE="false" export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/build" export INBUCKET_WEB_UIDIR="ui/dist"
export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h" export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
+19 -17
View File
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
jmessages := make([]*model.JSONMessageHeaderV1, len(messages)) jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages { for i, msg := range messages {
jmessages[i] = &model.JSONMessageHeaderV1{ jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name, Mailbox: name,
ID: msg.ID, ID: msg.ID,
From: stringutil.StringAddress(msg.From), From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To), To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
Size: msg.Size, PosixMillis: msg.Date.UnixNano() / 1000000,
Seen: msg.Seen, Size: msg.Size,
Seen: msg.Seen,
} }
} }
return web.RenderJSON(w, jmessages) return web.RenderJSON(w, jmessages)
@@ -77,15 +78,16 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
} }
return web.RenderJSON(w, return web.RenderJSON(w,
&model.JSONMessageV1{ &model.JSONMessageV1{
Mailbox: name, Mailbox: name,
ID: msg.ID, ID: msg.ID,
From: stringutil.StringAddress(msg.From), From: stringutil.StringAddress(msg.From),
To: stringutil.StringAddressList(msg.To), To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
Size: msg.Size, PosixMillis: msg.Date.UnixNano() / 1000000,
Seen: msg.Seen, Size: msg.Size,
Header: msg.Header(), Seen: msg.Seen,
Header: msg.Header(),
Body: &model.JSONMessageBodyV1{ Body: &model.JSONMessageBodyV1{
Text: msg.Text(), Text: msg.Text(),
HTML: msg.HTML(), HTML: msg.HTML(),
+3
View File
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>") decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[0]/subject", "subject 1") decodedStringEquals(t, result, "[0]/subject", "subject 1")
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00") decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
decodedNumberEquals(t, result, "[0]/size", 0) decodedNumberEquals(t, result, "[0]/size", 0)
decodedBoolEquals(t, result, "[0]/seen", false) decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/mailbox", "good") decodedStringEquals(t, result, "[1]/mailbox", "good")
@@ -120,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>") decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[1]/subject", "subject 2") decodedStringEquals(t, result, "[1]/subject", "subject 2")
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00") decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
decodedNumberEquals(t, result, "[1]/size", 0) decodedNumberEquals(t, result, "[1]/size", 0)
decodedBoolEquals(t, result, "[1]/seen", false) decodedBoolEquals(t, result, "[1]/seen", false)
@@ -221,6 +223,7 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "to/[0]", "<to1@host>") decodedStringEquals(t, result, "to/[0]", "<to1@host>")
decodedStringEquals(t, result, "subject", "subject 1") decodedStringEquals(t, result, "subject", "subject 1")
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00") decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
decodedNumberEquals(t, result, "size", 0) decodedNumberEquals(t, result, "size", 0)
decodedBoolEquals(t, result, "seen", true) decodedBoolEquals(t, result, "seen", true)
decodedStringEquals(t, result, "body/text", "This is some text") decodedStringEquals(t, result, "body/text", "This is some text")
+10 -8
View File
@@ -6,14 +6,15 @@ import (
// JSONMessageHeaderV1 contains the basic header data for a message // JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 struct { type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"` Mailbox string `json:"mailbox"`
ID string `json:"id"` ID string `json:"id"`
From string `json:"from"` From string `json:"from"`
To []string `json:"to"` To []string `json:"to"`
Subject string `json:"subject"` Subject string `json:"subject"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Size int64 `json:"size"` PosixMillis int64 `json:"posix-millis"`
Seen bool `json:"seen"` Size int64 `json:"size"`
Seen bool `json:"seen"`
} }
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody // JSONMessageV1 contains the same data as the header plus a JSONMessageBody
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
To []string `json:"to"` To []string `json:"to"`
Subject string `json:"subject"` Subject string `json:"subject"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"` Size int64 `json:"size"`
Seen bool `json:"seen"` Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"` Body *JSONMessageBodyV1 `json:"body"`
+8 -7
View File
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
return return
} }
header := &model.JSONMessageHeaderV1{ header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox, Mailbox: msg.Mailbox,
ID: msg.ID, ID: msg.ID,
From: msg.From, From: msg.From,
To: msg.To, To: msg.To,
Subject: msg.Subject, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
Size: msg.Size, PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
} }
if conn.WriteJSON(header) != nil { if conn.WriteJSON(header) != nil {
// Write failed // Write failed
+4 -2
View File
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
t.Errorf("JSON result%s", msg) t.Errorf("JSON result%s", msg)
return return
} }
if got, ok := val.(float64); ok { got, ok := val.(float64)
if ok {
if got == want { if got == want {
return return
} }
} }
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want) t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
path, val, val, int64(got), want, int64(want))
} }
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) { func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
+24
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"os"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -30,6 +31,29 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
} }
} }
// 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, // noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
// returning specified statusCode to the client. // returning specified statusCode to the client.
func noMatchHandler(statusCode int, message string) http.Handler { func noMatchHandler(statusCode int, message string) http.Handler {
+24 -9
View File
@@ -7,6 +7,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"path/filepath"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -47,7 +48,7 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent) m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
} }
// Initialize sets up things for unit tests or the Start() method // Initialize sets up things for unit tests or the Start() method.
func Initialize( func Initialize(
conf *config.Root, conf *config.Root,
shutdownChan chan bool, shutdownChan chan bool,
@@ -57,11 +58,11 @@ func Initialize(
rootConfig = conf rootConfig = conf
globalShutdown = shutdownChan globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers // NewContext() will use this DataStore for the web handlers.
msgHub = mh msgHub = mh
manager = mm manager = mm
// Content Paths // Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped") Msg("Web UI content mapped")
Router.Handle("/debug/vars", expvar.Handler()) Router.Handle("/debug/vars", expvar.Handler())
@@ -74,13 +75,27 @@ func Initialize(
log.Warn().Str("module", "web").Str("phase", "startup"). log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to /debug/pprof") Msg("Go pprof tools installed to /debug/pprof")
} }
// If no other route matches, attempt to service as UI element.
Router.PathPrefix("/").Handler(http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
Router.NotFoundHandler = noMatchHandler(http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(http.StatusMethodNotAllowed,
"Method not allowed for URI path")
// Session cookie setup // 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 := 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")
// Session cookie setup.
if conf.Web.CookieAuthKey == "" { if conf.Web.CookieAuthKey == "" {
log.Info().Str("module", "web").Str("phase", "startup"). log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key") Msg("Generating random cookie.auth.key")
+2
View File
@@ -23,6 +23,7 @@ type JSONMessage struct {
To []string `json:"to"` To []string `json:"to"`
Subject string `json:"subject"` Subject string `json:"subject"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"` Size int64 `json:"size"`
Seen bool `json:"seen"` Seen bool `json:"seen"`
Header map[string][]string `json:"header"` Header map[string][]string `json:"header"`
@@ -81,6 +82,7 @@ func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context)
To: stringutil.StringAddressList(msg.To), To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size, Size: msg.Size,
Seen: msg.Seen, Seen: msg.Seen,
Header: msg.Header(), Header: msg.Header(),
+15 -16
View File
@@ -1,9 +1,7 @@
# Inbucket User Interface # Inbucket User Interface
This directory contains the source code for the Inbucket web user interface. This directory contains the source code for the Inbucket web user interface.
It is written in [Elm] 0.18, a *delightful language for reliable webapps.* It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
The UI was bootstrapped with [Create Elm App].
## Development ## Development
@@ -11,15 +9,18 @@ With `$INBUCKET` as the root of the git repository.
One time setup (assuming [Node.js] is already installed): One time setup (assuming [Node.js] is already installed):
```
npm i create-elm-app@1.10.4 -g
```
In terminal 1 (inbucket daemon):
``` ```
cd $INBUCKET/ui cd $INBUCKET/ui
elm-app build 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 cd $INBUCKET
make make
etc/dev-start.sh etc/dev-start.sh
@@ -29,17 +30,15 @@ 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 UI is functional if this is your first time building Inbucket, but your dev/test
cycle should favor the development server below. cycle should favor the development server below.
In terminal 2 (elm-app development server): ### Terminal 2: webpack development server
``` ```
cd $INBUCKET/ui cd $INBUCKET/ui
elm-app start npm run dev
``` ```
[Create Elm App] will start a development HTTP server listening on port 3000. npm will start a development HTTP server listening on port 3000. You should use
You should use this server for UI development, as it features hot reload and the this server for UI development, as it features hot reload and the Elm debugger.
Elm debugger.
[Create Elm App]: https://github.com/halfzebra/create-elm-app
[Elm]: https://elm-lang.org [Elm]: https://elm-lang.org
[Node.js]: https://nodejs.org [Node.js]: https://nodejs.org
-1
View File
@@ -1 +0,0 @@
<!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"><link rel="manifest" href="/manifest.json"><link rel="shortcut icon" href="/favicon.png" type="image/png"><title>Inbucket</title><link href="/static/css/main.8d438738.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script type="text/javascript" src="/static/js/main.04b41a91.js"></script></body></html>
-15
View File
@@ -1,15 +0,0 @@
{
"short_name": "Elm App",
"name": "Create Elm App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
-1
View File
@@ -1 +0,0 @@
"use strict";var precacheConfig=[["/index.html","ed9e929e14fae109b0cd008ba5adb822"],["/static/css/main.8d438738.css","8d438738f900913d8f787dc7ef9b05f9"],["/static/js/main.04b41a91.js","c9dc7ec55e7e303354fc7423a2afaa38"]],cacheName="sw-precache-v3-sw-precache-webpack-plugin-"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,r){var a=new URL(e);return r&&a.pathname.match(r)||(a.search+=(a.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),a.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.hash="",n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],r=new URL(t,self.location),a=createCacheKey(r,hashParamName,n,/\.\w{8}\./);return[r.toString(),a]}));function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var r=new Request(n,{credentials:"same-origin"});return fetch(r).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);(t=urlsToCacheKeys.has(n))||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^(?!\\/__).*"],e.request.url)&&(n=new URL("/index.html",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"main":{"js":"/static/js/main.04b41a91.js","css":"/static/css/main.8d438738.css"},"":{"html":"/index.html"}}
View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+16
View File
@@ -0,0 +1,16 @@
<!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">
<title>Inbucket</title>
<link rel="shortcut icon" href="/favicon.png"></head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="text/javascript" src="/static/main.js"></script></body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
-35
View File
@@ -1,35 +0,0 @@
{
"version": "1.0.0",
"summary": "Elm powered UI for Inbucket",
"repository": "https://github.com/jhillyerd/inbucket.git",
"license": "MIT",
"source-directories": [
"src"
],
"exposed-modules": [],
"proxy": {
"/api": {
"target": "http://localhost:9000",
"ws": true
},
"/debug": {
"target": "http://localhost:9000"
},
"/serve": {
"target": "http://localhost:9000"
}
},
"dependencies": {
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
"basti1302/elm-human-readable-filesize": "1.1.0 <= v < 2.0.0",
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"elm-lang/navigation": "2.1.0 <= v < 3.0.0",
"elm-lang/svg": "2.0.0 <= v < 3.0.0",
"evancz/url-parser": "2.0.1 <= v < 3.0.0",
"jweir/sparkline": "3.0.0 <= v < 4.0.0",
"ryannhg/elm-date-format": "2.1.2 <= v < 3.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
+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": {}
}
}
+8339
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"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 --port 3000 --hot",
"errors": "webpack --mode development --display-error-details"
},
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.1.6",
"@babel/preset-env": "^7.1.6",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.1",
"elm-hot-webpack-loader": "^1.0.2",
"elm-webpack-loader": "^5.0.0",
"html-webpack-plugin": "^3.2.0",
"node-elm-compiler": "^5.0.1",
"style-loader": "^0.23.1",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.10"
}
}
+9 -15
View File
@@ -1,22 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<!-- <title>Inbucket</title>
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" type="image/png">
<title>Inbucket</title>
</head> </head>
<body> <body>
<noscript> <noscript>
You need to enable JavaScript to run this app. You need to enable JavaScript to run this app.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+4 -4
View File
@@ -1,10 +1,10 @@
{ {
"short_name": "Elm App", "short_name": "Inbucket",
"name": "Create Elm App Sample", "name": "Inbucket",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.png",
"sizes": "192x192", "sizes": "16x16",
"type": "image/png" "type": "image/png"
} }
], ],
+5 -15
View File
@@ -1,21 +1,11 @@
module Data.Date exposing (date) module Data.Date exposing (date)
import Date exposing (Date) import Json.Decode exposing (..)
import Json.Decode as Decode exposing (..) import Time exposing (Posix)
{-| Decode an ISO 8601 date {-| Decode a POSIX milliseconds timestamp.
-} -}
date : Decoder Date date : Decoder Posix
date = date =
let int |> map Time.millisToPosix
convert : String -> Decoder Date
convert raw =
case Date.fromString raw of
Ok date ->
succeed date
Err error ->
fail error
in
string |> andThen convert
+6 -6
View File
@@ -1,9 +1,9 @@
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder) module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
import Data.Date exposing (date) import Data.Date exposing (date)
import Date exposing (Date) import Json.Decode exposing (..)
import Json.Decode as Decode exposing (..)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (..)
import Time exposing (Posix)
type alias Message = type alias Message =
@@ -12,7 +12,7 @@ type alias Message =
, from : String , from : String
, to : List String , to : List String
, subject : String , subject : String
, date : Date , date : Posix
, size : Int , size : Int
, seen : Bool , seen : Bool
, text : String , text : String
@@ -30,13 +30,13 @@ type alias Attachment =
decoder : Decoder Message decoder : Decoder Message
decoder = decoder =
decode Message succeed Message
|> required "mailbox" string |> required "mailbox" string
|> required "id" string |> required "id" string
|> optional "from" string "" |> optional "from" string ""
|> required "to" (list string) |> required "to" (list string)
|> optional "subject" string "" |> optional "subject" string ""
|> required "date" date |> required "posix-millis" date
|> required "size" int |> required "size" int
|> required "seen" bool |> required "seen" bool
|> required "text" string |> required "text" string
@@ -46,7 +46,7 @@ decoder =
attachmentDecoder : Decoder Attachment attachmentDecoder : Decoder Attachment
attachmentDecoder = attachmentDecoder =
decode Attachment succeed Attachment
|> required "id" string |> required "id" string
|> required "filename" string |> required "filename" string
|> required "content-type" string |> required "content-type" string
+5 -5
View File
@@ -1,9 +1,9 @@
module Data.MessageHeader exposing (MessageHeader, decoder) module Data.MessageHeader exposing (MessageHeader, decoder)
import Data.Date exposing (date) import Data.Date exposing (date)
import Date exposing (Date) import Json.Decode exposing (..)
import Json.Decode as Decode exposing (..)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (..)
import Time exposing (Posix)
type alias MessageHeader = type alias MessageHeader =
@@ -12,7 +12,7 @@ type alias MessageHeader =
, from : String , from : String
, to : List String , to : List String
, subject : String , subject : String
, date : Date , date : Posix
, size : Int , size : Int
, seen : Bool , seen : Bool
} }
@@ -20,12 +20,12 @@ type alias MessageHeader =
decoder : Decoder MessageHeader decoder : Decoder MessageHeader
decoder = decoder =
decode MessageHeader succeed MessageHeader
|> required "mailbox" string |> required "mailbox" string
|> required "id" string |> required "id" string
|> optional "from" string "" |> optional "from" string ""
|> required "to" (list string) |> required "to" (list string)
|> optional "subject" string "" |> optional "subject" string ""
|> required "date" date |> required "posix-millis" date
|> required "size" int |> required "size" int
|> required "seen" bool |> required "seen" bool
+4 -2
View File
@@ -31,7 +31,7 @@ type alias Metrics =
decoder : Decoder Metrics decoder : Decoder Metrics
decoder = decoder =
decode Metrics succeed Metrics
|> requiredAt [ "memstats", "Sys" ] int |> requiredAt [ "memstats", "Sys" ] int
|> requiredAt [ "memstats", "HeapSys" ] int |> requiredAt [ "memstats", "HeapSys" ] int
|> requiredAt [ "memstats", "HeapAlloc" ] int |> requiredAt [ "memstats", "HeapAlloc" ] int
@@ -59,4 +59,6 @@ decoder =
-} -}
decodeIntList : Decoder (List Int) decodeIntList : Decoder (List Int)
decodeIntList = decodeIntList =
map (String.split "," >> List.map (String.toInt >> Result.withDefault 0)) string string
|> map (String.split ",")
|> map (List.map (String.toInt >> Maybe.withDefault 0))
+10 -8
View File
@@ -9,13 +9,15 @@ module Data.Session exposing
, update , update
) )
import Json.Decode as Decode exposing (..) import Browser.Navigation as Nav
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (..)
import Navigation exposing (Location) import Url exposing (Url)
type alias Session = type alias Session =
{ host : String { key : Nav.Key
, host : String
, flash : String , flash : String
, routing : Bool , routing : Bool
, persistent : Persistent , persistent : Persistent
@@ -36,9 +38,9 @@ type Msg
| AddRecent String | AddRecent String
init : Location -> Persistent -> Session init : Nav.Key -> Url -> Persistent -> Session
init location persistent = init key location persistent =
Session location.host "" True persistent Session key location.host "" True persistent
update : Msg -> Session -> Session update : Msg -> Session -> Session
@@ -84,10 +86,10 @@ none =
decoder : Decoder Persistent decoder : Decoder Persistent
decoder = decoder =
decode Persistent succeed Persistent
|> optional "recentMailboxes" (list string) [] |> optional "recentMailboxes" (list string) []
decodeValueWithDefault : Value -> Persistent decodeValueWithDefault : Value -> Persistent
decodeValueWithDefault = decodeValueWithDefault =
Decode.decodeValue decoder >> Result.withDefault { recentMailboxes = [] } decodeValue decoder >> Result.withDefault { recentMailboxes = [] }
+11 -15
View File
@@ -3,29 +3,29 @@ module HttpUtil exposing (delete, errorString, patch)
import Http import Http
delete : String -> Http.Request () delete : (Result Http.Error () -> msg) -> String -> Cmd msg
delete url = delete msg url =
Http.request Http.request
{ method = "DELETE" { method = "DELETE"
, headers = [] , headers = []
, url = url , url = url
, body = Http.emptyBody , body = Http.emptyBody
, expect = Http.expectStringResponse (\_ -> Ok ()) , expect = Http.expectWhatever msg
, timeout = Nothing , timeout = Nothing
, withCredentials = False , tracker = Nothing
} }
patch : String -> Http.Body -> Http.Request () patch : (Result Http.Error () -> msg) -> String -> Http.Body -> Cmd msg
patch url body = patch msg url body =
Http.request Http.request
{ method = "PATCH" { method = "PATCH"
, headers = [] , headers = []
, url = url , url = url
, body = body , body = body
, expect = Http.expectStringResponse (\_ -> Ok ()) , expect = Http.expectWhatever msg
, timeout = Nothing , timeout = Nothing
, withCredentials = False , tracker = Nothing
} }
@@ -42,11 +42,7 @@ errorString error =
"HTTP Network error" "HTTP Network error"
Http.BadStatus res -> Http.BadStatus res ->
"Bad HTTP status: " ++ toString res.status.code "Bad HTTP status: " ++ String.fromInt res
Http.BadPayload msg res -> Http.BadBody msg ->
"Bad HTTP payload: " "Bad HTTP body: " ++ msg
++ msg
++ " ("
++ toString res.status.code
++ ")"
+55 -41
View File
@@ -1,15 +1,17 @@
module Main exposing (Model, Msg(..), Page(..), applySession, init, main, pageSubscriptions, sessionChange, setRoute, subscriptions, update, updatePage, view) module Main exposing (main)
import Browser exposing (Document, UrlRequest)
import Browser.Navigation as Nav
import Data.Session as Session exposing (Session, decoder) import Data.Session as Session exposing (Session, decoder)
import Html exposing (..) import Html exposing (..)
import Json.Decode as Decode exposing (Value) import Json.Decode as D exposing (Value)
import Navigation exposing (Location)
import Page.Home as Home import Page.Home as Home
import Page.Mailbox as Mailbox import Page.Mailbox as Mailbox
import Page.Monitor as Monitor import Page.Monitor as Monitor
import Page.Status as Status import Page.Status as Status
import Ports import Ports
import Route exposing (Route) import Route exposing (Route)
import Url exposing (Url)
import Views.Page as Page exposing (ActivePage(..), frame) import Views.Page as Page exposing (ActivePage(..), frame)
@@ -31,11 +33,11 @@ type alias Model =
} }
init : Value -> Location -> ( Model, Cmd Msg ) init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
init sessionValue location = init sessionValue location key =
let let
session = session =
Session.init location (Session.decodeValueWithDefault sessionValue) Session.init key location (Session.decodeValueWithDefault sessionValue)
( subModel, _ ) = ( subModel, _ ) =
Home.init Home.init
@@ -47,16 +49,17 @@ init sessionValue location =
} }
route = route =
Route.fromLocation location Route.fromUrl location
in in
applySession (setRoute route model) applySession (setRoute route model)
type Msg type Msg
= SetRoute Route = SetRoute Route
| NewRoute Route | UrlChanged Url
| UpdateSession (Result String Session.Persistent) | LinkClicked UrlRequest
| MailboxNameInput String | UpdateSession (Result D.Error Session.Persistent)
| OnMailboxNameInput String
| ViewMailbox String | ViewMailbox String
| HomeMsg Home.Msg | HomeMsg Home.Msg
| MailboxMsg Mailbox.Msg | MailboxMsg Mailbox.Msg
@@ -76,9 +79,9 @@ subscriptions model =
] ]
sessionChange : Sub (Result String Session.Persistent) sessionChange : Sub (Result D.Error Session.Persistent)
sessionChange = sessionChange =
Ports.onSessionChange (Decode.decodeValue Session.decoder) Ports.onSessionChange (D.decodeValue Session.decoder)
pageSubscriptions : Page -> Sub Msg pageSubscriptions : Page -> Sub Msg
@@ -105,19 +108,27 @@ update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = update msg model =
applySession <| applySession <|
case msg of case msg of
SetRoute route -> LinkClicked req ->
-- Updates broser URL to requested route. case req of
( model, Route.newUrl route, Session.none ) Browser.Internal url ->
( model, Nav.pushUrl model.session.key (Url.toString url), Session.none )
NewRoute route -> Browser.External url ->
( model, Nav.load url, Session.none )
UrlChanged url ->
-- Responds to new browser URL. -- Responds to new browser URL.
if model.session.routing then if model.session.routing then
setRoute route model setRoute (Route.fromUrl url) model
else else
-- Skip once, but re-enable routing. -- Skip once, but re-enable routing.
( model, Cmd.none, Session.EnableRouting ) ( model, Cmd.none, Session.EnableRouting )
SetRoute route ->
-- Updates broser URL to requested route.
( model, Route.newUrl model.session.key route, Session.none )
UpdateSession (Ok persistent) -> UpdateSession (Ok persistent) ->
let let
session = session =
@@ -129,18 +140,17 @@ update msg model =
) )
UpdateSession (Err error) -> UpdateSession (Err error) ->
let ( model
_ = , Cmd.none
Debug.log "Error decoding session" error , Session.SetFlash ("Error decoding session: " ++ D.errorToString error)
in )
( model, Cmd.none, Session.none )
MailboxNameInput name -> OnMailboxNameInput name ->
( { model | mailboxName = name }, Cmd.none, Session.none ) ( { model | mailboxName = name }, Cmd.none, Session.none )
ViewMailbox name -> ViewMailbox name ->
( { model | mailboxName = "" } ( { model | mailboxName = "" }
, Route.newUrl (Route.Mailbox name) , Route.newUrl model.session.key (Route.Mailbox name)
, Session.none , Session.none
) )
@@ -230,10 +240,7 @@ setRoute route model =
Route.Status -> Route.Status ->
( { model | page = Status Status.init } ( { model | page = Status Status.init }
, Cmd.batch , Cmd.map StatusMsg Status.load
[ Ports.windowTitle "Inbucket Status"
, Cmd.map StatusMsg Status.load
]
, Session.none , Session.none
) )
in in
@@ -269,7 +276,7 @@ applySession ( model, cmd, sessionMsg ) =
-- VIEW -- VIEW
view : Model -> Html Msg view : Model -> Document Msg
view model = view model =
let let
mailbox = mailbox =
@@ -282,31 +289,36 @@ view model =
controls = controls =
{ viewMailbox = ViewMailbox { viewMailbox = ViewMailbox
, mailboxOnInput = MailboxNameInput , mailboxOnInput = OnMailboxNameInput
, mailboxValue = model.mailboxName , mailboxValue = model.mailboxName
, recentOptions = model.session.persistent.recentMailboxes , recentOptions = model.session.persistent.recentMailboxes
, recentActive = mailbox , recentActive = mailbox
} }
frame = framePage :
Page.frame controls model.session ActivePage
-> (msg -> Msg)
-> { title : String, content : Html msg }
-> Document Msg
framePage page toMsg { title, content } =
Document title
[ content
|> Html.map toMsg
|> Page.frame controls model.session page
]
in in
case model.page of case model.page of
Home subModel -> Home subModel ->
Html.map HomeMsg (Home.view model.session subModel) framePage Page.Other HomeMsg (Home.view model.session subModel)
|> frame Page.Other
Mailbox subModel -> Mailbox subModel ->
Html.map MailboxMsg (Mailbox.view model.session subModel) framePage Page.Mailbox MailboxMsg (Mailbox.view model.session subModel)
|> frame Page.Mailbox
Monitor subModel -> Monitor subModel ->
Html.map MonitorMsg (Monitor.view model.session subModel) framePage Page.Monitor MonitorMsg (Monitor.view model.session subModel)
|> frame Page.Monitor
Status subModel -> Status subModel ->
Html.map StatusMsg (Status.view model.session subModel) framePage Page.Status StatusMsg (Status.view model.session subModel)
|> frame Page.Status
@@ -315,9 +327,11 @@ view model =
main : Program Value Model Msg main : Program Value Model Msg
main = main =
Navigation.programWithFlags (Route.fromLocation >> NewRoute) Browser.application
{ init = init { init = init
, view = view , view = view
, update = update , update = update
, subscriptions = subscriptions , subscriptions = subscriptions
, onUrlChange = UrlChanged
, onUrlRequest = LinkClicked
} }
+21 -22
View File
@@ -19,18 +19,14 @@ type alias Model =
init : ( Model, Cmd Msg ) init : ( Model, Cmd Msg )
init = init =
( Model "" let
, Cmd.batch cmdGreeting =
[ Ports.windowTitle "Inbucket" Http.get
, cmdGreeting { url = "/serve/greeting"
] , expect = Http.expectString GreetingLoaded
) }
in
( Model "", cmdGreeting )
cmdGreeting : Cmd Msg
cmdGreeting =
Http.send GreetingResult <|
Http.getString "/serve/greeting"
@@ -38,16 +34,16 @@ cmdGreeting =
type Msg type Msg
= GreetingResult (Result Http.Error String) = GreetingLoaded (Result Http.Error String)
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg ) update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
update session msg model = update session msg model =
case msg of case msg of
GreetingResult (Ok greeting) -> GreetingLoaded (Ok greeting) ->
( Model greeting, Cmd.none, Session.none ) ( Model greeting, Cmd.none, Session.none )
GreetingResult (Err err) -> GreetingLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
@@ -55,12 +51,15 @@ update session msg model =
-- VIEW -- -- VIEW --
view : Session -> Model -> Html Msg view : Session -> Model -> { title : String, content : Html Msg }
view session model = view session model =
div [ id "page" ] { title = "Inbucket"
[ div , content =
[ class "greeting" div [ id "page" ]
, property "innerHTML" (Encode.string model.greeting) [ Html.node "rendered-html"
[ class "greeting"
, property "content" (Encode.string model.greeting)
]
[]
] ]
[] }
]
+114 -103
View File
@@ -3,15 +3,14 @@ module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, vie
import Data.Message as Message exposing (Message) import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Date exposing (Date) import DateFormat as DF
import DateFormat
import DateFormat.Relative as Relative import DateFormat.Relative as Relative
import Html exposing (..) import Html exposing (..)
import Html.Attributes import Html.Attributes
exposing exposing
( class ( class
, classList , classList
, downloadAs , download
, href , href
, id , id
, placeholder , placeholder
@@ -28,7 +27,7 @@ import Json.Encode as Encode
import Ports import Ports
import Route import Route
import Task import Task
import Time exposing (Time) import Time exposing (Posix)
@@ -65,7 +64,7 @@ type alias MessageList =
type alias VisibleMessage = type alias VisibleMessage =
{ message : Message { message : Message
, markSeenAt : Maybe Time , markSeenAt : Maybe Int
} }
@@ -74,13 +73,13 @@ type alias Model =
, state : State , state : State
, bodyMode : Body , bodyMode : Body
, searchInput : String , searchInput : String
, now : Date , now : Posix
} }
init : String -> Maybe MessageID -> ( Model, Cmd Msg ) init : String -> Maybe MessageID -> ( Model, Cmd Msg )
init mailboxName selection = init mailboxName selection =
( Model mailboxName (LoadingList selection) SafeHtmlBody "" (Date.fromTime 0) ( Model mailboxName (LoadingList selection) SafeHtmlBody "" (Time.millisToPosix 0)
, load mailboxName , load mailboxName
) )
@@ -88,8 +87,7 @@ init mailboxName selection =
load : String -> Cmd Msg load : String -> Cmd Msg
load mailboxName = load mailboxName =
Cmd.batch Cmd.batch
[ Ports.windowTitle (mailboxName ++ " - Inbucket") [ Task.perform Tick Time.now
, Task.perform Tick Time.now
, getList mailboxName , getList mailboxName
] ]
@@ -108,13 +106,13 @@ subscriptions model =
Sub.none Sub.none
else else
Time.every (250 * Time.millisecond) SeenTick Time.every 250 MarkSeenTick
_ -> _ ->
Sub.none Sub.none
in in
Sub.batch Sub.batch
[ Time.every (30 * Time.second) Tick [ Time.every (30 * 1000) Tick
, subSeen , subSeen
] ]
@@ -124,20 +122,20 @@ subscriptions model =
type Msg type Msg
= ClickMessage MessageID = ListLoaded (Result Http.Error (List MessageHeader))
| DeleteMessage Message | ClickMessage MessageID
| DeleteMessageResult (Result Http.Error ()) | OpenMessage MessageID
| ListResult (Result Http.Error (List MessageHeader)) | MessageLoaded (Result Http.Error Message)
| MarkSeenResult (Result Http.Error ())
| MessageResult (Result Http.Error Message)
| MessageBody Body | MessageBody Body
| OpenedTime Time | OpenedTime Posix
| Purge | MarkSeenTick Posix
| PurgeResult (Result Http.Error ()) | MarkedSeen (Result Http.Error ())
| SearchInput String | DeleteMessage Message
| SeenTick Time | DeletedMessage (Result Http.Error ())
| Tick Time | PurgeMailbox
| ViewMessage MessageID | PurgedMailbox (Result Http.Error ())
| OnSearchInput String
| Tick Posix
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg ) update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
@@ -147,28 +145,25 @@ update session msg model =
( updateSelected model id ( updateSelected model id
, Cmd.batch , Cmd.batch
[ -- Update browser location. [ -- Update browser location.
Route.newUrl (Route.Message model.mailboxName id) Route.newUrl session.key (Route.Message model.mailboxName id)
, getMessage model.mailboxName id , getMessage model.mailboxName id
] ]
, Session.DisableRouting , Session.DisableRouting
) )
ViewMessage id -> OpenMessage id ->
( updateSelected model id updateOpenMessage session model id
, getMessage model.mailboxName id
, Session.AddRecent model.mailboxName
)
DeleteMessage message -> DeleteMessage message ->
updateDeleteMessage model message updateDeleteMessage model message
DeleteMessageResult (Ok _) -> DeletedMessage (Ok _) ->
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
DeleteMessageResult (Err err) -> DeletedMessage (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
ListResult (Ok headers) -> ListLoaded (Ok headers) ->
case model.state of case model.state of
LoadingList selection -> LoadingList selection ->
let let
@@ -179,8 +174,7 @@ update session msg model =
in in
case selection of case selection of
Just id -> Just id ->
-- Recurse to select message id. updateOpenMessage session newModel id
update session (ViewMessage id) newModel
Nothing -> Nothing ->
( newModel, Cmd.none, Session.AddRecent model.mailboxName ) ( newModel, Cmd.none, Session.AddRecent model.mailboxName )
@@ -188,25 +182,25 @@ update session msg model =
_ -> _ ->
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
ListResult (Err err) -> ListLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MarkSeenResult (Ok _) -> MarkedSeen (Ok _) ->
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
MarkSeenResult (Err err) -> MarkedSeen (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MessageResult (Ok message) -> MessageLoaded (Ok message) ->
updateMessageResult model message updateMessageResult model message
MessageResult (Err err) -> MessageLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MessageBody bodyMode -> MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none, Session.none ) ( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
SearchInput searchInput -> OnSearchInput searchInput ->
updateSearchInput model searchInput updateSearchInput model searchInput
OpenedTime time -> OpenedTime time ->
@@ -216,13 +210,17 @@ update session msg model =
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
else else
-- Set delay before reporting message as seen to backend. -- Set 1500ms delay before reporting message as seen to backend.
let
markSeenAt =
Time.posixToMillis time + 1500
in
( { model ( { model
| state = | state =
ShowingList list ShowingList list
(ShowingMessage (ShowingMessage
{ visible { visible
| markSeenAt = Just (time + (1.5 * Time.second)) | markSeenAt = Just markSeenAt
} }
) )
} }
@@ -233,21 +231,21 @@ update session msg model =
_ -> _ ->
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
Purge -> PurgeMailbox ->
updatePurge model updatePurge model
PurgeResult (Ok _) -> PurgedMailbox (Ok _) ->
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
PurgeResult (Err err) -> PurgedMailbox (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
SeenTick now -> MarkSeenTick now ->
case model.state of case model.state of
ShowingList _ (ShowingMessage { message, markSeenAt }) -> ShowingList _ (ShowingMessage { message, markSeenAt }) ->
case markSeenAt of case markSeenAt of
Just deadline -> Just deadline ->
if now >= deadline then if Time.posixToMillis now >= deadline then
updateMarkMessageSeen model message updateMarkMessageSeen model message
else else
@@ -260,7 +258,7 @@ update session msg model =
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
Tick now -> Tick now ->
( { model | now = Date.fromTime now }, Cmd.none, Session.none ) ( { model | now = now }, Cmd.none, Session.none )
{-| Replace the currently displayed message. {-| Replace the currently displayed message.
@@ -298,8 +296,7 @@ updatePurge model =
cmd = cmd =
"/api/v1/mailbox/" "/api/v1/mailbox/"
++ model.mailboxName ++ model.mailboxName
|> HttpUtil.delete |> HttpUtil.delete PurgedMailbox
|> Http.send PurgeResult
in in
case model.state of case model.state of
ShowingList list _ -> ShowingList list _ ->
@@ -371,8 +368,7 @@ updateDeleteMessage model message =
"/api/v1/mailbox/" ++ message.mailbox ++ "/" ++ message.id "/api/v1/mailbox/" ++ message.mailbox ++ "/" ++ message.id
cmd = cmd =
HttpUtil.delete url HttpUtil.delete DeletedMessage url
|> Http.send DeleteMessageResult
filter f messageList = filter f messageList =
{ messageList | headers = List.filter f messageList.headers } { messageList | headers = List.filter f messageList.headers }
@@ -411,8 +407,7 @@ updateMarkMessageSeen model message =
-- desired change in the body. -- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ] Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody |> Http.jsonBody
|> HttpUtil.patch url |> HttpUtil.patch MarkedSeen url
|> Http.send MarkSeenResult
map f messageList = map f messageList =
{ messageList | headers = List.map f messageList.headers } { messageList | headers = List.map f messageList.headers }
@@ -435,14 +430,24 @@ updateMarkMessageSeen model message =
( model, Cmd.none, Session.none ) ( model, Cmd.none, Session.none )
updateOpenMessage : Session -> Model -> String -> ( Model, Cmd Msg, Session.Msg )
updateOpenMessage session model id =
( updateSelected model id
, getMessage model.mailboxName id
, Session.AddRecent model.mailboxName
)
getList : String -> Cmd Msg getList : String -> Cmd Msg
getList mailboxName = getList mailboxName =
let let
url = url =
"/api/v1/mailbox/" ++ mailboxName "/api/v1/mailbox/" ++ mailboxName
in in
Http.get url (Decode.list MessageHeader.decoder) Http.get
|> Http.send ListResult { url = url
, expect = Http.expectJson ListLoaded (Decode.list MessageHeader.decoder)
}
getMessage : String -> MessageID -> Cmd Msg getMessage : String -> MessageID -> Cmd Msg
@@ -451,37 +456,42 @@ getMessage mailboxName id =
url = url =
"/serve/m/" ++ mailboxName ++ "/" ++ id "/serve/m/" ++ mailboxName ++ "/" ++ id
in in
Http.get url Message.decoder Http.get
|> Http.send MessageResult { url = url
, expect = Http.expectJson MessageLoaded Message.decoder
}
-- VIEW -- VIEW
view : Session -> Model -> Html Msg view : Session -> Model -> { title : String, content : Html Msg }
view session model = view session model =
div [ id "page", class "mailbox" ] { title = model.mailboxName ++ " - Inbucket"
[ viewMessageList session model , content =
, main_ div [ id "page", class "mailbox" ]
[ id "message" ] [ viewMessageList session model
[ case model.state of , main_
ShowingList _ NoMessage -> [ id "message" ]
text [ case model.state of
("Select a message on the left," ShowingList _ NoMessage ->
++ " or enter a different username into the box on upper right." text
) ("Select a message on the left,"
++ " or enter a different username into the box on upper right."
)
ShowingList _ (ShowingMessage { message }) -> ShowingList _ (ShowingMessage { message }) ->
viewMessage message model.bodyMode viewMessage message model.bodyMode
ShowingList _ (Transitioning { message }) -> ShowingList _ (Transitioning { message }) ->
viewMessage message model.bodyMode viewMessage message model.bodyMode
_ -> _ ->
text "" text ""
]
] ]
] }
viewMessageList : Session -> Model -> Html Msg viewMessageList : Session -> Model -> Html Msg
@@ -491,11 +501,11 @@ viewMessageList session model =
[ input [ input
[ type_ "search" [ type_ "search"
, placeholder "search" , placeholder "search"
, onInput SearchInput , onInput OnSearchInput
, value model.searchInput , value model.searchInput
] ]
[] []
, button [ onClick Purge ] [ text "Purge" ] , button [ onClick PurgeMailbox ] [ text "Purge" ]
] ]
, case model.state of , case model.state of
LoadingList _ -> LoadingList _ ->
@@ -530,14 +540,14 @@ messageChip model selected message =
viewMessage : Message -> Body -> Html Msg viewMessage : Message -> Body -> Html Msg
viewMessage message bodyMode = viewMessage message bodyMode =
let let
sourceUrl message = sourceUrl =
"/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source" "/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
in in
div [] div []
[ div [ class "button-bar" ] [ div [ class "button-bar" ]
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ] [ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
, a , a
[ href (sourceUrl message), target "_blank" ] [ href sourceUrl, target "_blank" ]
[ button [] [ text "Source" ] ] [ button [] [ text "Source" ] ]
] ]
, dl [ id "message-header" ] , dl [ id "message-header" ]
@@ -584,10 +594,10 @@ messageBody message bodyMode =
, article [ class "message-body" ] , article [ class "message-body" ]
[ case bodyMode of [ case bodyMode of
SafeHtmlBody -> SafeHtmlBody ->
div [ property "innerHTML" (Encode.string message.html) ] [] Html.node "rendered-html" [ property "content" (Encode.string message.html) ] []
TextBody -> TextBody ->
div [ property "innerHTML" (Encode.string message.text) ] [] Html.node "rendered-html" [ property "content" (Encode.string message.text) ] []
] ]
] ]
@@ -616,34 +626,35 @@ attachmentRow baseUrl attach =
[ a [ href url, target "_blank" ] [ text attach.fileName ] [ a [ href url, target "_blank" ] [ text attach.fileName ]
, text (" (" ++ attach.contentType ++ ") ") , text (" (" ++ attach.contentType ++ ") ")
] ]
, td [] [ a [ href url, downloadAs attach.fileName, class "button" ] [ text "Download" ] ] , td [] [ a [ href url, download attach.fileName, class "button" ] [ text "Download" ] ]
] ]
relativeDate : Model -> Date -> Html Msg relativeDate : Model -> Posix -> Html Msg
relativeDate model date = relativeDate model date =
Relative.relativeTime model.now date |> text Relative.relativeTime model.now date |> text
verboseDate : Date -> Html Msg verboseDate : Posix -> Html Msg
verboseDate date = verboseDate date =
DateFormat.format text <|
[ DateFormat.monthNameFull DF.format
, DateFormat.text " " [ DF.monthNameFull
, DateFormat.dayOfMonthSuffix , DF.text " "
, DateFormat.text ", " , DF.dayOfMonthSuffix
, DateFormat.yearNumber , DF.text ", "
, DateFormat.text " " , DF.yearNumber
, DateFormat.hourNumber , DF.text " "
, DateFormat.text ":" , DF.hourNumber
, DateFormat.minuteFixed , DF.text ":"
, DateFormat.text ":" , DF.minuteFixed
, DateFormat.secondFixed , DF.text ":"
, DateFormat.text " " , DF.secondFixed
, DateFormat.amPmUppercase , DF.text " "
] , DF.amPmUppercase
date ]
|> text Time.utc
date
+54 -63
View File
@@ -2,22 +2,14 @@ module Page.Monitor exposing (Model, Msg, init, subscriptions, update, view)
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Date exposing (Date) import DateFormat as DF
import DateFormat
exposing
( amPmUppercase
, dayOfMonthFixed
, format
, hourNumber
, minuteFixed
, monthNameFirstThree
)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events as Events import Html.Events as Events
import Json.Decode as D import Json.Decode as D
import Ports import Ports
import Route import Route
import Time exposing (Posix)
@@ -30,14 +22,14 @@ type alias Model =
} }
type MonitorMessage
= Connected Bool
| Message MessageHeader
init : ( Model, Cmd Msg ) init : ( Model, Cmd Msg )
init = init =
( Model False [] ( Model False [], Ports.monitorCommand True )
, Cmd.batch
[ Ports.windowTitle "Inbucket Monitor"
, Ports.monitorCommand True
]
)
@@ -55,7 +47,7 @@ subscriptions model =
|> D.decodeValue |> D.decodeValue
|> Ports.monitorMessage |> Ports.monitorMessage
in in
Sub.map MonitorResult monitorMessage Sub.map MessageReceived monitorMessage
@@ -63,30 +55,25 @@ subscriptions model =
type Msg type Msg
= MonitorResult (Result String MonitorMessage) = MessageReceived (Result D.Error MonitorMessage)
| OpenMessage MessageHeader | OpenMessage MessageHeader
type MonitorMessage
= Connected Bool
| Message MessageHeader
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg ) update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
update session msg model = update session msg model =
case msg of case msg of
MonitorResult (Ok (Connected status)) -> MessageReceived (Ok (Connected status)) ->
( { model | connected = status }, Cmd.none, Session.none ) ( { model | connected = status }, Cmd.none, Session.none )
MonitorResult (Ok (Message msg)) -> MessageReceived (Ok (Message header)) ->
( { model | messages = msg :: model.messages }, Cmd.none, Session.none ) ( { model | messages = header :: model.messages }, Cmd.none, Session.none )
MonitorResult (Err err) -> MessageReceived (Err err) ->
( model, Cmd.none, Session.SetFlash err ) ( model, Cmd.none, Session.SetFlash (D.errorToString err) )
OpenMessage msg -> OpenMessage header ->
( model ( model
, Route.newUrl (Route.Message msg.mailbox msg.id) , Route.newUrl session.key (Route.Message header.mailbox header.id)
, Session.none , Session.none
) )
@@ -95,32 +82,35 @@ update session msg model =
-- VIEW -- VIEW
view : Session -> Model -> Html Msg view : Session -> Model -> { title : String, content : Html Msg }
view session model = view session model =
div [ id "page" ] { title = "Inbucket Monitor"
[ h1 [] [ text "Monitor" ] , content =
, p [] div [ id "page" ]
[ text "Messages will be listed here shortly after delivery. " [ h1 [] [ text "Monitor" ]
, em [] , p []
[ text [ text "Messages will be listed here shortly after delivery. "
(if model.connected then , em []
"Connected." [ text
(if model.connected then
"Connected."
else else
"Disconnected!" "Disconnected!"
) )
]
]
, table [ id "monitor" ]
[ thead []
[ th [] [ text "Date" ]
, th [ class "desktop" ] [ text "From" ]
, th [] [ text "Mailbox" ]
, th [] [ text "Subject" ]
]
, tbody [] (List.map viewMessage model.messages)
] ]
] ]
, table [ id "monitor" ] }
[ thead []
[ th [] [ text "Date" ]
, th [ class "desktop" ] [ text "From" ]
, th [] [ text "Mailbox" ]
, th [] [ text "Subject" ]
]
, tbody [] (List.map viewMessage model.messages)
]
]
viewMessage : MessageHeader -> Html Msg viewMessage : MessageHeader -> Html Msg
@@ -133,18 +123,19 @@ viewMessage message =
] ]
shortDate : Date -> Html Msg shortDate : Posix -> Html Msg
shortDate date = shortDate date =
format DF.format
[ dayOfMonthFixed [ DF.dayOfMonthFixed
, DateFormat.text "-" , DF.text "-"
, monthNameFirstThree , DF.monthNameAbbreviated
, DateFormat.text " " , DF.text " "
, hourNumber , DF.hourNumber
, DateFormat.text ":" , DF.text ":"
, minuteFixed , DF.minuteFixed
, DateFormat.text " " , DF.text " "
, amPmUppercase , DF.amPmUppercase
] ]
Time.utc
date date
|> text |> text
+70 -61
View File
@@ -7,9 +7,9 @@ import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Http exposing (Error) import Http exposing (Error)
import HttpUtil import HttpUtil
import Sparkline exposing (DataSet, Point, Size, sparkline) import Sparkline as Spark
import Svg.Attributes as SvgAttrib import Svg.Attributes as SvgAttrib
import Time exposing (Time) import Time exposing (Posix)
@@ -40,8 +40,8 @@ type alias Metric =
{ label : String { label : String
, value : Int , value : Int
, formatter : Int -> String , formatter : Int -> String
, graph : DataSet -> Html Msg , graph : Spark.DataSet -> Html Msg
, history : DataSet , history : Spark.DataSet
, minutes : Int , minutes : Int
} }
@@ -67,7 +67,7 @@ init =
} }
initDataSet : DataSet initDataSet : Spark.DataSet
initDataSet = initDataSet =
List.range 0 59 List.range 0 59
|> List.map (\x -> ( toFloat x, 0 )) |> List.map (\x -> ( toFloat x, 0 ))
@@ -84,7 +84,7 @@ load =
subscriptions : Model -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions model = subscriptions model =
Time.every (10 * Time.second) Tick Time.every (10 * 1000) Tick
@@ -92,17 +92,17 @@ subscriptions model =
type Msg type Msg
= NewMetrics (Result Http.Error Metrics) = MetricsReceived (Result Http.Error Metrics)
| Tick Time | Tick Posix
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg ) update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
update session msg model = update session msg model =
case msg of case msg of
NewMetrics (Ok metrics) -> MetricsReceived (Ok metrics) ->
( updateMetrics metrics model, Cmd.none, Session.none ) ( updateMetrics metrics model, Cmd.none, Session.none )
NewMetrics (Err err) -> MetricsReceived (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
Tick time -> Tick time ->
@@ -207,46 +207,51 @@ updateRemoteTotal metric value history =
getMetrics : Cmd Msg getMetrics : Cmd Msg
getMetrics = getMetrics =
Http.get "/debug/vars" Metrics.decoder Http.get
|> Http.send NewMetrics { url = "/debug/vars"
, expect = Http.expectJson MetricsReceived Metrics.decoder
}
-- VIEW -- -- VIEW --
view : Session -> Model -> Html Msg view : Session -> Model -> { title : String, content : Html Msg }
view session model = view session model =
div [ id "page" ] { title = "Inbucket Status"
[ h1 [] [ text "Status" ] , content =
, case model.metrics of div [ id "page" ]
Nothing -> [ h1 [] [ text "Status" ]
div [] [ text "Loading metrics..." ] , case model.metrics of
Nothing ->
div [] [ text "Loading metrics..." ]
Just metrics -> Just metrics ->
div [] div []
[ framePanel "General Metrics" [ framePanel "General Metrics"
[ viewMetric model.sysMem [ viewMetric model.sysMem
, viewMetric model.heapSize , viewMetric model.heapSize
, viewMetric model.heapUsed , viewMetric model.heapUsed
, viewMetric model.heapObjects , viewMetric model.heapObjects
, viewMetric model.goRoutines , viewMetric model.goRoutines
, viewMetric model.webSockets , viewMetric model.webSockets
]
, framePanel "SMTP Metrics"
[ viewMetric model.smtpConnOpen
, viewMetric model.smtpConnTotal
, viewMetric model.smtpReceivedTotal
, viewMetric model.smtpErrorsTotal
, viewMetric model.smtpWarnsTotal
]
, framePanel "Storage Metrics"
[ viewMetric model.retentionDeletesTotal
, viewMetric model.retainedCount
, viewMetric model.retainedSize
]
] ]
, framePanel "SMTP Metrics" ]
[ viewMetric model.smtpConnOpen }
, viewMetric model.smtpConnTotal
, viewMetric model.smtpReceivedTotal
, viewMetric model.smtpErrorsTotal
, viewMetric model.smtpWarnsTotal
]
, framePanel "Storage Metrics"
[ viewMetric model.retentionDeletesTotal
, viewMetric model.retainedCount
, viewMetric model.retainedSize
]
]
]
viewMetric : Metric -> Html Msg viewMetric : Metric -> Html Msg
@@ -256,7 +261,7 @@ viewMetric metric =
, div [ class "value" ] [ text (metric.formatter metric.value) ] , div [ class "value" ] [ text (metric.formatter metric.value) ]
, div [ class "graph" ] , div [ class "graph" ]
[ metric.graph metric.history [ metric.graph metric.history
, text ("(" ++ toString metric.minutes ++ "min)") , text ("(" ++ String.fromInt metric.minutes ++ "min)")
] ]
] ]
@@ -278,30 +283,34 @@ graphNull =
div [] [] div [] []
graphSize : Size graphSize : Spark.Size
graphSize = graphSize =
( 180, 16, 0, 0 ) { width = 180
, height = 16
, marginLR = 0
, marginTB = 0
}
areaStyle : Sparkline.Param a -> Sparkline.Param a areaStyle : Spark.Param a -> Spark.Param a
areaStyle = areaStyle =
Sparkline.Style Spark.Style
[ SvgAttrib.fill "rgba(50,100,255,0.3)" [ SvgAttrib.fill "rgba(50,100,255,0.3)"
, SvgAttrib.stroke "rgba(50,100,255,1.0)" , SvgAttrib.stroke "rgba(50,100,255,1.0)"
, SvgAttrib.strokeWidth "1.0" , SvgAttrib.strokeWidth "1.0"
] ]
barStyle : Sparkline.Param a -> Sparkline.Param a barStyle : Spark.Param a -> Spark.Param a
barStyle = barStyle =
Sparkline.Style Spark.Style
[ SvgAttrib.fill "rgba(50,200,50,0.7)" [ SvgAttrib.fill "rgba(50,200,50,0.7)"
] ]
zeroStyle : Sparkline.Param a -> Sparkline.Param a zeroStyle : Spark.Param a -> Spark.Param a
zeroStyle = zeroStyle =
Sparkline.Style Spark.Style
[ SvgAttrib.stroke "rgba(0,0,0,0.2)" [ SvgAttrib.stroke "rgba(0,0,0,0.2)"
, SvgAttrib.strokeWidth "1.0" , SvgAttrib.strokeWidth "1.0"
] ]
@@ -309,7 +318,7 @@ zeroStyle =
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values). {-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
-} -}
graphChange : DataSet -> Html a graphChange : Spark.DataSet -> Html a
graphChange data = graphChange data =
let let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph. -- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
@@ -321,16 +330,16 @@ graphChange data =
Just point -> Just point ->
Tuple.first point Tuple.first point
in in
sparkline graphSize Spark.sparkline graphSize
[ Sparkline.Bar 2.5 data |> barStyle [ Spark.Bar 2.5 data |> barStyle
, Sparkline.ZeroLine |> zeroStyle , Spark.ZeroLine |> zeroStyle
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ] , Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
] ]
{-| Zero based area graph, for charting absolute values relative to 0. {-| Zero based area graph, for charting absolute values relative to 0.
-} -}
graphZero : DataSet -> Html a graphZero : Spark.DataSet -> Html a
graphZero data = graphZero data =
let let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph. -- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
@@ -342,10 +351,10 @@ graphZero data =
Just point -> Just point ->
Tuple.first point Tuple.first point
in in
sparkline graphSize Spark.sparkline graphSize
[ Sparkline.Area data |> areaStyle [ Spark.Area data |> areaStyle
, Sparkline.ZeroLine |> zeroStyle , Spark.ZeroLine |> zeroStyle
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ] , Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
] ]
@@ -400,4 +409,4 @@ fmtInt n =
else else
thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str
in in
thousands (toString n) thousands (String.fromInt n)
-4
View File
@@ -3,7 +3,6 @@ port module Ports exposing
, monitorMessage , monitorMessage
, onSessionChange , onSessionChange
, storeSession , storeSession
, windowTitle
) )
import Data.Session exposing (Persistent) import Data.Session exposing (Persistent)
@@ -20,6 +19,3 @@ port onSessionChange : (Value -> msg) -> Sub msg
port storeSession : Persistent -> Cmd msg port storeSession : Persistent -> Cmd msg
port windowTitle : String -> Cmd msg
+34 -32
View File
@@ -1,9 +1,10 @@
module Route exposing (Route(..), fromLocation, href, modifyUrl, newUrl) module Route exposing (Route(..), fromUrl, href, modifyUrl, newUrl)
import Browser.Navigation as Navigation exposing (Key)
import Html exposing (Attribute) import Html exposing (Attribute)
import Html.Attributes as Attr import Html.Attributes as Attr
import Navigation exposing (Location) import Url exposing (Url)
import UrlParser as Url exposing ((</>), Parser, parseHash, s, string) import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
type Route type Route
@@ -15,17 +16,20 @@ type Route
| Status | Status
matcher : Parser (Route -> a) a {-| Routes our application handles.
matcher = -}
Url.oneOf routes : List (Parser (Route -> a) a)
[ Url.map Home (s "") routes =
, Url.map Message (s "m" </> string </> string) [ map Home top
, Url.map Mailbox (s "m" </> string) , map Message (s "m" </> string </> string)
, Url.map Monitor (s "monitor") , map Mailbox (s "m" </> string)
, Url.map Status (s "status") , map Monitor (s "monitor")
] , map Status (s "status")
]
{-| Convert route to a URI.
-}
routeToString : Route -> String routeToString : Route -> String
routeToString page = routeToString page =
let let
@@ -49,37 +53,35 @@ routeToString page =
Status -> Status ->
[ "status" ] [ "status" ]
in in
"/#/" ++ String.join "/" pieces "/" ++ String.join "/" pieces
-- PUBLIC HELPERS -- PUBLIC HELPERS
href : Route -> Attribute msg href : Key -> Route -> Attribute msg
href route = href key route =
Attr.href (routeToString route) Attr.href (routeToString route)
modifyUrl : Route -> Cmd msg modifyUrl : Key -> Route -> Cmd msg
modifyUrl = modifyUrl key =
routeToString >> Navigation.modifyUrl routeToString >> Navigation.replaceUrl key
newUrl : Route -> Cmd msg newUrl : Key -> Route -> Cmd msg
newUrl = newUrl key =
routeToString >> Navigation.newUrl routeToString >> Navigation.pushUrl key
fromLocation : Location -> Route {-| Returns the Route for a given URL.
fromLocation location = -}
if String.isEmpty location.hash then fromUrl : Url -> Route
Home fromUrl location =
case Parser.parse (oneOf routes) location of
Nothing ->
Unknown location.path
else Just route ->
case parseHash matcher location of route
Nothing ->
Unknown location.hash
Just route ->
route
+26 -18
View File
@@ -10,7 +10,9 @@ import Html.Attributes
, href , href
, id , id
, placeholder , placeholder
, rel
, selected , selected
, target
, type_ , type_
, value , value
) )
@@ -39,10 +41,11 @@ frame controls session page content =
div [ id "app" ] div [ id "app" ]
[ header [] [ header []
[ ul [ class "navbar", attribute "role" "navigation" ] [ ul [ class "navbar", attribute "role" "navigation" ]
[ li [ id "navbar-brand" ] [ a [ Route.href Route.Home ] [ text "@ inbucket" ] ] [ li [ id "navbar-brand" ]
, navbarLink page Route.Monitor [ text "Monitor" ] [ a [ Route.href session.key Route.Home ] [ text "@ inbucket" ] ]
, navbarLink page Route.Status [ text "Status" ] , navbarLink session page Route.Monitor [ text "Monitor" ]
, navbarRecent page controls , navbarLink session page Route.Status [ text "Status" ]
, navbarRecent session page controls
, li [ id "navbar-mailbox" ] , li [ id "navbar-mailbox" ]
[ form [ Events.onSubmit (controls.viewMailbox controls.mailboxValue) ] [ form [ Events.onSubmit (controls.viewMailbox controls.mailboxValue) ]
[ input [ input
@@ -61,33 +64,35 @@ frame controls session page content =
, content , content
, footer [] , footer []
[ div [ id "footer" ] [ div [ id "footer" ]
[ a [ href "https://www.inbucket.org" ] [ text "Inbucket" ] [ externalLink "https://www.inbucket.org" "Inbucket"
, text " is an open source projected hosted at " , text " is an open source projected hosted at "
, a [ href "https://github.com/jhillyerd/inbucket" ] [ text "GitHub" ] , externalLink "https://github.com/jhillyerd/inbucket" "GitHub"
, text "." , text "."
] ]
] ]
] ]
navbarLink : ActivePage -> Route -> List (Html a) -> Html a externalLink : String -> String -> Html a
navbarLink page route linkContent = externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Session -> ActivePage -> Route -> List (Html a) -> Html a
navbarLink session page route linkContent =
li [ classList [ ( "navbar-active", isActive page route ) ] ] li [ classList [ ( "navbar-active", isActive page route ) ] ]
[ a [ Route.href route ] linkContent ] [ a [ Route.href session.key route ] linkContent ]
{-| Renders list of recent mailboxes, selecting the currently active mailbox. {-| Renders list of recent mailboxes, selecting the currently active mailbox.
-} -}
navbarRecent : ActivePage -> FrameControls msg -> Html msg navbarRecent : Session -> ActivePage -> FrameControls msg -> Html msg
navbarRecent page controls = navbarRecent session page controls =
let let
recentItemLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
active = active =
page == Mailbox page == Mailbox
-- Navbar tab title, is current mailbox when active. -- Recent tab title is the name of the current mailbox when active.
title = title =
if active then if active then
controls.recentActive controls.recentActive
@@ -95,20 +100,23 @@ navbarRecent page controls =
else else
"Recent Mailboxes" "Recent Mailboxes"
-- Items to show in recent list, doesn't include active mailbox. -- Mailboxes to show in recent list, doesn't include active mailbox.
items = recentMailboxes =
if active then if active then
List.tail controls.recentOptions |> Maybe.withDefault [] List.tail controls.recentOptions |> Maybe.withDefault []
else else
controls.recentOptions controls.recentOptions
recentLink mailbox =
a [ Route.href session.key (Route.Mailbox mailbox) ] [ text mailbox ]
in in
li li
[ id "navbar-recent" [ id "navbar-recent"
, classList [ ( "navbar-dropdown", True ), ( "navbar-active", active ) ] , classList [ ( "navbar-dropdown", True ), ( "navbar-active", active ) ]
] ]
[ span [] [ text title ] [ span [] [ text title ]
, div [ class "navbar-dropdown-content" ] (List.map recentItemLink items) , div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
] ]
+6 -10
View File
@@ -1,10 +1,13 @@
import './main.css' import './main.css'
import { Main } from './Main.elm' import { Elm } from './Main.elm'
import registerServiceWorker from './registerServiceWorker'
import registerMonitorPorts from './registerMonitor' import registerMonitorPorts from './registerMonitor'
import './renderedHtml'
// App startup. // App startup.
var app = Main.embed(document.getElementById('root'), sessionObject()) var app = Elm.Main.init({
node: document.getElementById('root'),
flags: sessionObject()
})
// Message monitor. // Message monitor.
registerMonitorPorts(app) registerMonitorPorts(app)
@@ -31,10 +34,3 @@ function sessionObject() {
} }
return null return null
} }
// Window title.
app.ports.windowTitle.subscribe(function (title) {
document.title = title
})
registerServiceWorker()
-108
View File
@@ -1,108 +0,0 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (!isLocalhost) {
// Is not local host. Just register service worker
registerValidSW(swUrl);
} else {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
}
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}
+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
}
}
)
+70
View File
@@ -0,0 +1,70 @@
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
module.exports = (env, argv) => {
const production = argv.mode === 'production'
const config = {
output: {
filename: 'static/[name].js',
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
exclude: [/elm-stuff/, /node_modules/],
loader: 'babel-loader',
query: {
presets: [
'@babel/preset-env',
],
},
},
{
test: /\.elm$/,
exclude: [/elm-stuff/, /node_modules/],
use: [
{ loader: 'elm-hot-webpack-loader' },
{
loader: 'elm-webpack-loader',
options: {
debug: !production,
optimize: production,
},
},
],
},
{
test: /\.css$/,
exclude: [/node_modules/],
loader: ['style-loader', 'css-loader'],
},
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'public/index.html',
favicon: 'public/favicon.png',
}),
],
devServer: {
inline: true,
historyApiFallback: true,
stats: { colors: true },
overlay: true,
open: true,
proxy: [{
context: ['/api', '/debug', '/serve'],
target: 'http://localhost:9000',
ws: true,
}],
watchOptions: {
ignored: /node_modules/,
},
},
}
if (argv.hot) {
config.plugins.push(new webpack.HotModuleReplacementPlugin())
}
return config
}