1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-08-01 08:39:50 +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.
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]
+1 -1
View File
@@ -6,7 +6,7 @@ export INBUCKET_LOGLEVEL="debug"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
export INBUCKET_WEB_TEMPLATECACHE="false"
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_PARAMS="path:/tmp/inbucket"
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))
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(),
+3
View File
@@ -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")
+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"`
+8 -7
View File
@@ -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
+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)
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) {
+24
View File
@@ -2,6 +2,7 @@ package web
import (
"net/http"
"os"
"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,
// returning specified statusCode to the client.
func noMatchHandler(statusCode int, message string) http.Handler {
+24 -9
View File
@@ -7,6 +7,7 @@ import (
"net"
"net/http"
"net/http/pprof"
"path/filepath"
"time"
"github.com/gorilla/mux"
@@ -47,7 +48,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,
@@ -57,11 +58,11 @@ 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
// Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.Handle("/debug/vars", expvar.Handler())
@@ -74,13 +75,27 @@ func Initialize(
log.Warn().Str("module", "web").Str("phase", "startup").
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 == "" {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key")
+2
View File
@@ -23,6 +23,7 @@ type JSONMessage 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"`
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),
Subject: msg.Subject,
Date: msg.Date,
PosixMillis: msg.Date.UnixNano() / 1000000,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
+15 -16
View File
@@ -1,9 +1,7 @@
# Inbucket 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.*
The UI was bootstrapped with [Create Elm App].
It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
## Development
@@ -11,15 +9,18 @@ With `$INBUCKET` as the root of the git repository.
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
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
make
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
cycle should favor the development server below.
In terminal 2 (elm-app development server):
### Terminal 2: webpack development server
```
cd $INBUCKET/ui
elm-app start
npm run dev
```
[Create Elm App] 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.
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.
[Create Elm App]: https://github.com/halfzebra/create-elm-app
[Elm]: https://elm-lang.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>
<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">
<!--
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>
<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>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>
+4 -4
View File
@@ -1,10 +1,10 @@
{
"short_name": "Elm App",
"name": "Create Elm App Sample",
"short_name": "Inbucket",
"name": "Inbucket",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"src": "favicon.png",
"sizes": "16x16",
"type": "image/png"
}
],
+5 -15
View File
@@ -1,21 +1,11 @@
module Data.Date exposing (date)
import Date exposing (Date)
import Json.Decode as Decode exposing (..)
import Json.Decode exposing (..)
import Time exposing (Posix)
{-| Decode an ISO 8601 date
{-| Decode a POSIX milliseconds timestamp.
-}
date : Decoder Date
date : Decoder Posix
date =
let
convert : String -> Decoder Date
convert raw =
case Date.fromString raw of
Ok date ->
succeed date
Err error ->
fail error
in
string |> andThen convert
int |> map Time.millisToPosix
+6 -6
View File
@@ -1,9 +1,9 @@
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
import Data.Date exposing (date)
import Date exposing (Date)
import Json.Decode as Decode exposing (..)
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Time exposing (Posix)
type alias Message =
@@ -12,7 +12,7 @@ type alias Message =
, from : String
, to : List String
, subject : String
, date : Date
, date : Posix
, size : Int
, seen : Bool
, text : String
@@ -30,13 +30,13 @@ type alias Attachment =
decoder : Decoder Message
decoder =
decode Message
succeed Message
|> required "mailbox" string
|> required "id" string
|> optional "from" string ""
|> required "to" (list string)
|> optional "subject" string ""
|> required "date" date
|> required "posix-millis" date
|> required "size" int
|> required "seen" bool
|> required "text" string
@@ -46,7 +46,7 @@ decoder =
attachmentDecoder : Decoder Attachment
attachmentDecoder =
decode Attachment
succeed Attachment
|> required "id" string
|> required "filename" string
|> required "content-type" string
+5 -5
View File
@@ -1,9 +1,9 @@
module Data.MessageHeader exposing (MessageHeader, decoder)
import Data.Date exposing (date)
import Date exposing (Date)
import Json.Decode as Decode exposing (..)
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Time exposing (Posix)
type alias MessageHeader =
@@ -12,7 +12,7 @@ type alias MessageHeader =
, from : String
, to : List String
, subject : String
, date : Date
, date : Posix
, size : Int
, seen : Bool
}
@@ -20,12 +20,12 @@ type alias MessageHeader =
decoder : Decoder MessageHeader
decoder =
decode MessageHeader
succeed MessageHeader
|> required "mailbox" string
|> required "id" string
|> optional "from" string ""
|> required "to" (list string)
|> optional "subject" string ""
|> required "date" date
|> required "posix-millis" date
|> required "size" int
|> required "seen" bool
+4 -2
View File
@@ -31,7 +31,7 @@ type alias Metrics =
decoder : Decoder Metrics
decoder =
decode Metrics
succeed Metrics
|> requiredAt [ "memstats", "Sys" ] int
|> requiredAt [ "memstats", "HeapSys" ] int
|> requiredAt [ "memstats", "HeapAlloc" ] int
@@ -59,4 +59,6 @@ decoder =
-}
decodeIntList : Decoder (List Int)
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
)
import Json.Decode as Decode exposing (..)
import Browser.Navigation as Nav
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Navigation exposing (Location)
import Url exposing (Url)
type alias Session =
{ host : String
{ key : Nav.Key
, host : String
, flash : String
, routing : Bool
, persistent : Persistent
@@ -36,9 +38,9 @@ type Msg
| AddRecent String
init : Location -> Persistent -> Session
init location persistent =
Session location.host "" True persistent
init : Nav.Key -> Url -> Persistent -> Session
init key location persistent =
Session key location.host "" True persistent
update : Msg -> Session -> Session
@@ -84,10 +86,10 @@ none =
decoder : Decoder Persistent
decoder =
decode Persistent
succeed Persistent
|> optional "recentMailboxes" (list string) []
decodeValueWithDefault : Value -> Persistent
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
delete : String -> Http.Request ()
delete url =
delete : (Result Http.Error () -> msg) -> String -> Cmd msg
delete msg url =
Http.request
{ method = "DELETE"
, headers = []
, url = url
, body = Http.emptyBody
, expect = Http.expectStringResponse (\_ -> Ok ())
, expect = Http.expectWhatever msg
, timeout = Nothing
, withCredentials = False
, tracker = Nothing
}
patch : String -> Http.Body -> Http.Request ()
patch url body =
patch : (Result Http.Error () -> msg) -> String -> Http.Body -> Cmd msg
patch msg url body =
Http.request
{ method = "PATCH"
, headers = []
, url = url
, body = body
, expect = Http.expectStringResponse (\_ -> Ok ())
, expect = Http.expectWhatever msg
, timeout = Nothing
, withCredentials = False
, tracker = Nothing
}
@@ -42,11 +42,7 @@ errorString error =
"HTTP Network error"
Http.BadStatus res ->
"Bad HTTP status: " ++ toString res.status.code
"Bad HTTP status: " ++ String.fromInt res
Http.BadPayload msg res ->
"Bad HTTP payload: "
++ msg
++ " ("
++ toString res.status.code
++ ")"
Http.BadBody msg ->
"Bad HTTP body: " ++ msg
+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 Html exposing (..)
import Json.Decode as Decode exposing (Value)
import Navigation exposing (Location)
import Json.Decode as D exposing (Value)
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 Url exposing (Url)
import Views.Page as Page exposing (ActivePage(..), frame)
@@ -31,11 +33,11 @@ type alias Model =
}
init : Value -> Location -> ( Model, Cmd Msg )
init sessionValue location =
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
init sessionValue location key =
let
session =
Session.init location (Session.decodeValueWithDefault sessionValue)
Session.init key location (Session.decodeValueWithDefault sessionValue)
( subModel, _ ) =
Home.init
@@ -47,16 +49,17 @@ init sessionValue location =
}
route =
Route.fromLocation location
Route.fromUrl location
in
applySession (setRoute route model)
type Msg
= SetRoute Route
| NewRoute Route
| UpdateSession (Result String Session.Persistent)
| MailboxNameInput String
| UrlChanged Url
| LinkClicked UrlRequest
| UpdateSession (Result D.Error Session.Persistent)
| OnMailboxNameInput String
| ViewMailbox String
| HomeMsg Home.Msg
| MailboxMsg Mailbox.Msg
@@ -76,9 +79,9 @@ subscriptions model =
]
sessionChange : Sub (Result String Session.Persistent)
sessionChange : Sub (Result D.Error Session.Persistent)
sessionChange =
Ports.onSessionChange (Decode.decodeValue Session.decoder)
Ports.onSessionChange (D.decodeValue Session.decoder)
pageSubscriptions : Page -> Sub Msg
@@ -105,19 +108,27 @@ update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
applySession <|
case msg of
SetRoute route ->
-- Updates broser URL to requested route.
( model, Route.newUrl route, Session.none )
LinkClicked req ->
case req of
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.
if model.session.routing then
setRoute route model
setRoute (Route.fromUrl url) model
else
-- Skip once, but re-enable routing.
( 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) ->
let
session =
@@ -129,18 +140,17 @@ update msg model =
)
UpdateSession (Err error) ->
let
_ =
Debug.log "Error decoding session" error
in
( model, Cmd.none, Session.none )
( model
, Cmd.none
, Session.SetFlash ("Error decoding session: " ++ D.errorToString error)
)
MailboxNameInput name ->
OnMailboxNameInput name ->
( { model | mailboxName = name }, Cmd.none, Session.none )
ViewMailbox name ->
( { model | mailboxName = "" }
, Route.newUrl (Route.Mailbox name)
, Route.newUrl model.session.key (Route.Mailbox name)
, Session.none
)
@@ -230,10 +240,7 @@ setRoute route model =
Route.Status ->
( { model | page = Status Status.init }
, Cmd.batch
[ Ports.windowTitle "Inbucket Status"
, Cmd.map StatusMsg Status.load
]
, Cmd.map StatusMsg Status.load
, Session.none
)
in
@@ -269,7 +276,7 @@ applySession ( model, cmd, sessionMsg ) =
-- VIEW
view : Model -> Html Msg
view : Model -> Document Msg
view model =
let
mailbox =
@@ -282,31 +289,36 @@ view model =
controls =
{ viewMailbox = ViewMailbox
, mailboxOnInput = MailboxNameInput
, mailboxOnInput = OnMailboxNameInput
, mailboxValue = model.mailboxName
, recentOptions = model.session.persistent.recentMailboxes
, recentActive = mailbox
}
frame =
Page.frame controls model.session
framePage :
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
case model.page of
Home subModel ->
Html.map HomeMsg (Home.view model.session subModel)
|> frame Page.Other
framePage Page.Other HomeMsg (Home.view model.session subModel)
Mailbox subModel ->
Html.map MailboxMsg (Mailbox.view model.session subModel)
|> frame Page.Mailbox
framePage Page.Mailbox MailboxMsg (Mailbox.view model.session subModel)
Monitor subModel ->
Html.map MonitorMsg (Monitor.view model.session subModel)
|> frame Page.Monitor
framePage Page.Monitor MonitorMsg (Monitor.view model.session subModel)
Status subModel ->
Html.map StatusMsg (Status.view model.session subModel)
|> frame Page.Status
framePage Page.Status StatusMsg (Status.view model.session subModel)
@@ -315,9 +327,11 @@ view model =
main : Program Value Model Msg
main =
Navigation.programWithFlags (Route.fromLocation >> NewRoute)
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlChange = UrlChanged
, onUrlRequest = LinkClicked
}
+21 -22
View File
@@ -19,18 +19,14 @@ type alias Model =
init : ( Model, Cmd Msg )
init =
( Model ""
, Cmd.batch
[ Ports.windowTitle "Inbucket"
, cmdGreeting
]
)
cmdGreeting : Cmd Msg
cmdGreeting =
Http.send GreetingResult <|
Http.getString "/serve/greeting"
let
cmdGreeting =
Http.get
{ url = "/serve/greeting"
, expect = Http.expectString GreetingLoaded
}
in
( Model "", cmdGreeting )
@@ -38,16 +34,16 @@ cmdGreeting =
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 =
case msg of
GreetingResult (Ok greeting) ->
GreetingLoaded (Ok greeting) ->
( Model greeting, Cmd.none, Session.none )
GreetingResult (Err err) ->
GreetingLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
@@ -55,12 +51,15 @@ update session msg model =
-- VIEW --
view : Session -> Model -> Html Msg
view : Session -> Model -> { title : String, content : Html Msg }
view session model =
div [ id "page" ]
[ div
[ class "greeting"
, property "innerHTML" (Encode.string model.greeting)
{ title = "Inbucket"
, content =
div [ id "page" ]
[ 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.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session)
import Date exposing (Date)
import DateFormat
import DateFormat as DF
import DateFormat.Relative as Relative
import Html exposing (..)
import Html.Attributes
exposing
( class
, classList
, downloadAs
, download
, href
, id
, placeholder
@@ -28,7 +27,7 @@ import Json.Encode as Encode
import Ports
import Route
import Task
import Time exposing (Time)
import Time exposing (Posix)
@@ -65,7 +64,7 @@ type alias MessageList =
type alias VisibleMessage =
{ message : Message
, markSeenAt : Maybe Time
, markSeenAt : Maybe Int
}
@@ -74,13 +73,13 @@ type alias Model =
, state : State
, bodyMode : Body
, searchInput : String
, now : Date
, now : Posix
}
init : String -> Maybe MessageID -> ( Model, Cmd Msg )
init mailboxName selection =
( Model mailboxName (LoadingList selection) SafeHtmlBody "" (Date.fromTime 0)
( Model mailboxName (LoadingList selection) SafeHtmlBody "" (Time.millisToPosix 0)
, load mailboxName
)
@@ -88,8 +87,7 @@ init mailboxName selection =
load : String -> Cmd Msg
load mailboxName =
Cmd.batch
[ Ports.windowTitle (mailboxName ++ " - Inbucket")
, Task.perform Tick Time.now
[ Task.perform Tick Time.now
, getList mailboxName
]
@@ -108,13 +106,13 @@ subscriptions model =
Sub.none
else
Time.every (250 * Time.millisecond) SeenTick
Time.every 250 MarkSeenTick
_ ->
Sub.none
in
Sub.batch
[ Time.every (30 * Time.second) Tick
[ Time.every (30 * 1000) Tick
, subSeen
]
@@ -124,20 +122,20 @@ subscriptions model =
type Msg
= ClickMessage MessageID
| DeleteMessage Message
| DeleteMessageResult (Result Http.Error ())
| ListResult (Result Http.Error (List MessageHeader))
| MarkSeenResult (Result Http.Error ())
| MessageResult (Result Http.Error Message)
= ListLoaded (Result Http.Error (List MessageHeader))
| ClickMessage MessageID
| OpenMessage MessageID
| MessageLoaded (Result Http.Error Message)
| MessageBody Body
| OpenedTime Time
| Purge
| PurgeResult (Result Http.Error ())
| SearchInput String
| SeenTick Time
| Tick Time
| ViewMessage MessageID
| OpenedTime Posix
| MarkSeenTick Posix
| MarkedSeen (Result Http.Error ())
| DeleteMessage Message
| DeletedMessage (Result Http.Error ())
| PurgeMailbox
| PurgedMailbox (Result Http.Error ())
| OnSearchInput String
| Tick Posix
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
@@ -147,28 +145,25 @@ update session msg model =
( updateSelected model id
, Cmd.batch
[ -- Update browser location.
Route.newUrl (Route.Message model.mailboxName id)
Route.newUrl session.key (Route.Message model.mailboxName id)
, getMessage model.mailboxName id
]
, Session.DisableRouting
)
ViewMessage id ->
( updateSelected model id
, getMessage model.mailboxName id
, Session.AddRecent model.mailboxName
)
OpenMessage id ->
updateOpenMessage session model id
DeleteMessage message ->
updateDeleteMessage model message
DeleteMessageResult (Ok _) ->
DeletedMessage (Ok _) ->
( model, Cmd.none, Session.none )
DeleteMessageResult (Err err) ->
DeletedMessage (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
ListResult (Ok headers) ->
ListLoaded (Ok headers) ->
case model.state of
LoadingList selection ->
let
@@ -179,8 +174,7 @@ update session msg model =
in
case selection of
Just id ->
-- Recurse to select message id.
update session (ViewMessage id) newModel
updateOpenMessage session newModel id
Nothing ->
( newModel, Cmd.none, Session.AddRecent model.mailboxName )
@@ -188,25 +182,25 @@ update session msg model =
_ ->
( model, Cmd.none, Session.none )
ListResult (Err err) ->
ListLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MarkSeenResult (Ok _) ->
MarkedSeen (Ok _) ->
( model, Cmd.none, Session.none )
MarkSeenResult (Err err) ->
MarkedSeen (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MessageResult (Ok message) ->
MessageLoaded (Ok message) ->
updateMessageResult model message
MessageResult (Err err) ->
MessageLoaded (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
SearchInput searchInput ->
OnSearchInput searchInput ->
updateSearchInput model searchInput
OpenedTime time ->
@@ -216,13 +210,17 @@ update session msg model =
( model, Cmd.none, Session.none )
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
| state =
ShowingList list
(ShowingMessage
{ visible
| markSeenAt = Just (time + (1.5 * Time.second))
| markSeenAt = Just markSeenAt
}
)
}
@@ -233,21 +231,21 @@ update session msg model =
_ ->
( model, Cmd.none, Session.none )
Purge ->
PurgeMailbox ->
updatePurge model
PurgeResult (Ok _) ->
PurgedMailbox (Ok _) ->
( model, Cmd.none, Session.none )
PurgeResult (Err err) ->
PurgedMailbox (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
SeenTick now ->
MarkSeenTick now ->
case model.state of
ShowingList _ (ShowingMessage { message, markSeenAt }) ->
case markSeenAt of
Just deadline ->
if now >= deadline then
if Time.posixToMillis now >= deadline then
updateMarkMessageSeen model message
else
@@ -260,7 +258,7 @@ update session msg model =
( model, Cmd.none, Session.none )
Tick now ->
( { model | now = Date.fromTime now }, Cmd.none, Session.none )
( { model | now = now }, Cmd.none, Session.none )
{-| Replace the currently displayed message.
@@ -298,8 +296,7 @@ updatePurge model =
cmd =
"/api/v1/mailbox/"
++ model.mailboxName
|> HttpUtil.delete
|> Http.send PurgeResult
|> HttpUtil.delete PurgedMailbox
in
case model.state of
ShowingList list _ ->
@@ -371,8 +368,7 @@ updateDeleteMessage model message =
"/api/v1/mailbox/" ++ message.mailbox ++ "/" ++ message.id
cmd =
HttpUtil.delete url
|> Http.send DeleteMessageResult
HttpUtil.delete DeletedMessage url
filter f messageList =
{ messageList | headers = List.filter f messageList.headers }
@@ -411,8 +407,7 @@ updateMarkMessageSeen model message =
-- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody
|> HttpUtil.patch url
|> Http.send MarkSeenResult
|> HttpUtil.patch MarkedSeen url
map f messageList =
{ messageList | headers = List.map f messageList.headers }
@@ -435,14 +430,24 @@ updateMarkMessageSeen model message =
( 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 mailboxName =
let
url =
"/api/v1/mailbox/" ++ mailboxName
in
Http.get url (Decode.list MessageHeader.decoder)
|> Http.send ListResult
Http.get
{ url = url
, expect = Http.expectJson ListLoaded (Decode.list MessageHeader.decoder)
}
getMessage : String -> MessageID -> Cmd Msg
@@ -451,37 +456,42 @@ getMessage mailboxName id =
url =
"/serve/m/" ++ mailboxName ++ "/" ++ id
in
Http.get url Message.decoder
|> Http.send MessageResult
Http.get
{ url = url
, expect = Http.expectJson MessageLoaded Message.decoder
}
-- VIEW
view : Session -> Model -> Html Msg
view : Session -> Model -> { title : String, content : Html Msg }
view session model =
div [ id "page", class "mailbox" ]
[ viewMessageList session model
, main_
[ id "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."
)
{ title = model.mailboxName ++ " - Inbucket"
, content =
div [ id "page", class "mailbox" ]
[ viewMessageList session model
, main_
[ id "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 message model.bodyMode
ShowingList _ (ShowingMessage { message }) ->
viewMessage message model.bodyMode
ShowingList _ (Transitioning { message }) ->
viewMessage message model.bodyMode
ShowingList _ (Transitioning { message }) ->
viewMessage message model.bodyMode
_ ->
text ""
_ ->
text ""
]
]
]
}
viewMessageList : Session -> Model -> Html Msg
@@ -491,11 +501,11 @@ viewMessageList session model =
[ input
[ type_ "search"
, placeholder "search"
, onInput SearchInput
, onInput OnSearchInput
, value model.searchInput
]
[]
, button [ onClick Purge ] [ text "Purge" ]
, button [ onClick PurgeMailbox ] [ text "Purge" ]
]
, case model.state of
LoadingList _ ->
@@ -530,14 +540,14 @@ messageChip model selected message =
viewMessage : Message -> Body -> Html Msg
viewMessage message bodyMode =
let
sourceUrl message =
sourceUrl =
"/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
in
div []
[ div [ class "button-bar" ]
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
, a
[ href (sourceUrl message), target "_blank" ]
[ href sourceUrl, target "_blank" ]
[ button [] [ text "Source" ] ]
]
, dl [ id "message-header" ]
@@ -584,10 +594,10 @@ messageBody message bodyMode =
, article [ class "message-body" ]
[ case bodyMode of
SafeHtmlBody ->
div [ property "innerHTML" (Encode.string message.html) ] []
Html.node "rendered-html" [ property "content" (Encode.string message.html) ] []
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 ]
, 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 =
Relative.relativeTime model.now date |> text
verboseDate : Date -> Html Msg
verboseDate : Posix -> Html Msg
verboseDate date =
DateFormat.format
[ DateFormat.monthNameFull
, DateFormat.text " "
, DateFormat.dayOfMonthSuffix
, DateFormat.text ", "
, DateFormat.yearNumber
, DateFormat.text " "
, DateFormat.hourNumber
, DateFormat.text ":"
, DateFormat.minuteFixed
, DateFormat.text ":"
, DateFormat.secondFixed
, DateFormat.text " "
, DateFormat.amPmUppercase
]
date
|> text
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
]
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.Session as Session exposing (Session)
import Date exposing (Date)
import DateFormat
exposing
( amPmUppercase
, dayOfMonthFixed
, format
, hourNumber
, minuteFixed
, monthNameFirstThree
)
import DateFormat as DF
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events as Events
import Json.Decode as D
import Ports
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 False []
, Cmd.batch
[ Ports.windowTitle "Inbucket Monitor"
, Ports.monitorCommand True
]
)
( Model False [], Ports.monitorCommand True )
@@ -55,7 +47,7 @@ subscriptions model =
|> D.decodeValue
|> Ports.monitorMessage
in
Sub.map MonitorResult monitorMessage
Sub.map MessageReceived monitorMessage
@@ -63,30 +55,25 @@ subscriptions model =
type Msg
= MonitorResult (Result String MonitorMessage)
= MessageReceived (Result D.Error MonitorMessage)
| OpenMessage MessageHeader
type MonitorMessage
= Connected Bool
| Message MessageHeader
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
update session msg model =
case msg of
MonitorResult (Ok (Connected status)) ->
MessageReceived (Ok (Connected status)) ->
( { model | connected = status }, Cmd.none, Session.none )
MonitorResult (Ok (Message msg)) ->
( { model | messages = msg :: model.messages }, Cmd.none, Session.none )
MessageReceived (Ok (Message header)) ->
( { model | messages = header :: model.messages }, Cmd.none, Session.none )
MonitorResult (Err err) ->
( model, Cmd.none, Session.SetFlash err )
MessageReceived (Err err) ->
( model, Cmd.none, Session.SetFlash (D.errorToString err) )
OpenMessage msg ->
OpenMessage header ->
( model
, Route.newUrl (Route.Message msg.mailbox msg.id)
, Route.newUrl session.key (Route.Message header.mailbox header.id)
, Session.none
)
@@ -95,32 +82,35 @@ update session msg model =
-- VIEW
view : Session -> Model -> Html Msg
view : Session -> Model -> { title : String, content : Html Msg }
view session model =
div [ id "page" ]
[ h1 [] [ text "Monitor" ]
, p []
[ text "Messages will be listed here shortly after delivery. "
, em []
[ text
(if model.connected then
"Connected."
{ title = "Inbucket Monitor"
, content =
div [ id "page" ]
[ h1 [] [ text "Monitor" ]
, p []
[ text "Messages will be listed here shortly after delivery. "
, em []
[ text
(if model.connected then
"Connected."
else
"Disconnected!"
)
else
"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
@@ -133,18 +123,19 @@ viewMessage message =
]
shortDate : Date -> Html Msg
shortDate : Posix -> Html Msg
shortDate date =
format
[ dayOfMonthFixed
, DateFormat.text "-"
, monthNameFirstThree
, DateFormat.text " "
, hourNumber
, DateFormat.text ":"
, minuteFixed
, DateFormat.text " "
, amPmUppercase
DF.format
[ DF.dayOfMonthFixed
, DF.text "-"
, DF.monthNameAbbreviated
, DF.text " "
, DF.hourNumber
, DF.text ":"
, DF.minuteFixed
, DF.text " "
, DF.amPmUppercase
]
Time.utc
date
|> text
+70 -61
View File
@@ -7,9 +7,9 @@ import Html exposing (..)
import Html.Attributes exposing (..)
import Http exposing (Error)
import HttpUtil
import Sparkline exposing (DataSet, Point, Size, sparkline)
import Sparkline as Spark
import Svg.Attributes as SvgAttrib
import Time exposing (Time)
import Time exposing (Posix)
@@ -40,8 +40,8 @@ type alias Metric =
{ label : String
, value : Int
, formatter : Int -> String
, graph : DataSet -> Html Msg
, history : DataSet
, graph : Spark.DataSet -> Html Msg
, history : Spark.DataSet
, minutes : Int
}
@@ -67,7 +67,7 @@ init =
}
initDataSet : DataSet
initDataSet : Spark.DataSet
initDataSet =
List.range 0 59
|> List.map (\x -> ( toFloat x, 0 ))
@@ -84,7 +84,7 @@ load =
subscriptions : Model -> Sub Msg
subscriptions model =
Time.every (10 * Time.second) Tick
Time.every (10 * 1000) Tick
@@ -92,17 +92,17 @@ subscriptions model =
type Msg
= NewMetrics (Result Http.Error Metrics)
| Tick Time
= MetricsReceived (Result Http.Error Metrics)
| Tick Posix
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
update session msg model =
case msg of
NewMetrics (Ok metrics) ->
MetricsReceived (Ok metrics) ->
( updateMetrics metrics model, Cmd.none, Session.none )
NewMetrics (Err err) ->
MetricsReceived (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
Tick time ->
@@ -207,46 +207,51 @@ updateRemoteTotal metric value history =
getMetrics : Cmd Msg
getMetrics =
Http.get "/debug/vars" Metrics.decoder
|> Http.send NewMetrics
Http.get
{ url = "/debug/vars"
, expect = Http.expectJson MetricsReceived Metrics.decoder
}
-- VIEW --
view : Session -> Model -> Html Msg
view : Session -> Model -> { title : String, content : Html Msg }
view session model =
div [ id "page" ]
[ h1 [] [ text "Status" ]
, case model.metrics of
Nothing ->
div [] [ text "Loading metrics..." ]
{ title = "Inbucket Status"
, content =
div [ id "page" ]
[ h1 [] [ text "Status" ]
, case model.metrics of
Nothing ->
div [] [ text "Loading metrics..." ]
Just metrics ->
div []
[ framePanel "General Metrics"
[ viewMetric model.sysMem
, viewMetric model.heapSize
, viewMetric model.heapUsed
, viewMetric model.heapObjects
, viewMetric model.goRoutines
, viewMetric model.webSockets
Just metrics ->
div []
[ framePanel "General Metrics"
[ viewMetric model.sysMem
, viewMetric model.heapSize
, viewMetric model.heapUsed
, viewMetric model.heapObjects
, viewMetric model.goRoutines
, 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
@@ -256,7 +261,7 @@ viewMetric metric =
, div [ class "value" ] [ text (metric.formatter metric.value) ]
, div [ class "graph" ]
[ metric.graph metric.history
, text ("(" ++ toString metric.minutes ++ "min)")
, text ("(" ++ String.fromInt metric.minutes ++ "min)")
]
]
@@ -278,30 +283,34 @@ graphNull =
div [] []
graphSize : Size
graphSize : Spark.Size
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 =
Sparkline.Style
Spark.Style
[ SvgAttrib.fill "rgba(50,100,255,0.3)"
, SvgAttrib.stroke "rgba(50,100,255,1.0)"
, SvgAttrib.strokeWidth "1.0"
]
barStyle : Sparkline.Param a -> Sparkline.Param a
barStyle : Spark.Param a -> Spark.Param a
barStyle =
Sparkline.Style
Spark.Style
[ SvgAttrib.fill "rgba(50,200,50,0.7)"
]
zeroStyle : Sparkline.Param a -> Sparkline.Param a
zeroStyle : Spark.Param a -> Spark.Param a
zeroStyle =
Sparkline.Style
Spark.Style
[ SvgAttrib.stroke "rgba(0,0,0,0.2)"
, SvgAttrib.strokeWidth "1.0"
]
@@ -309,7 +318,7 @@ zeroStyle =
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
-}
graphChange : DataSet -> Html a
graphChange : Spark.DataSet -> Html a
graphChange data =
let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
@@ -321,16 +330,16 @@ graphChange data =
Just point ->
Tuple.first point
in
sparkline graphSize
[ Sparkline.Bar 2.5 data |> barStyle
, Sparkline.ZeroLine |> zeroStyle
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ]
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 : DataSet -> Html a
graphZero : Spark.DataSet -> Html a
graphZero data =
let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
@@ -342,10 +351,10 @@ graphZero data =
Just point ->
Tuple.first point
in
sparkline graphSize
[ Sparkline.Area data |> areaStyle
, Sparkline.ZeroLine |> zeroStyle
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ]
Spark.sparkline graphSize
[ Spark.Area data |> areaStyle
, Spark.ZeroLine |> zeroStyle
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
]
@@ -400,4 +409,4 @@ fmtInt n =
else
thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str
in
thousands (toString n)
thousands (String.fromInt n)
-4
View File
@@ -3,7 +3,6 @@ port module Ports exposing
, monitorMessage
, onSessionChange
, storeSession
, windowTitle
)
import Data.Session exposing (Persistent)
@@ -20,6 +19,3 @@ port onSessionChange : (Value -> msg) -> Sub 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.Attributes as Attr
import Navigation exposing (Location)
import UrlParser as Url exposing ((</>), Parser, parseHash, s, string)
import Url exposing (Url)
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
type Route
@@ -15,17 +16,20 @@ type Route
| Status
matcher : Parser (Route -> a) a
matcher =
Url.oneOf
[ Url.map Home (s "")
, Url.map Message (s "m" </> string </> string)
, Url.map Mailbox (s "m" </> string)
, Url.map Monitor (s "monitor")
, Url.map Status (s "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.
-}
routeToString : Route -> String
routeToString page =
let
@@ -49,37 +53,35 @@ routeToString page =
Status ->
[ "status" ]
in
"/#/" ++ String.join "/" pieces
"/" ++ String.join "/" pieces
-- PUBLIC HELPERS
href : Route -> Attribute msg
href route =
href : Key -> Route -> Attribute msg
href key route =
Attr.href (routeToString route)
modifyUrl : Route -> Cmd msg
modifyUrl =
routeToString >> Navigation.modifyUrl
modifyUrl : Key -> Route -> Cmd msg
modifyUrl key =
routeToString >> Navigation.replaceUrl key
newUrl : Route -> Cmd msg
newUrl =
routeToString >> Navigation.newUrl
newUrl : Key -> Route -> Cmd msg
newUrl key =
routeToString >> Navigation.pushUrl key
fromLocation : Location -> Route
fromLocation location =
if String.isEmpty location.hash then
Home
{-| Returns the Route for a given URL.
-}
fromUrl : Url -> Route
fromUrl location =
case Parser.parse (oneOf routes) location of
Nothing ->
Unknown location.path
else
case parseHash matcher location of
Nothing ->
Unknown location.hash
Just route ->
route
Just route ->
route
+26 -18
View File
@@ -10,7 +10,9 @@ import Html.Attributes
, href
, id
, placeholder
, rel
, selected
, target
, type_
, value
)
@@ -39,10 +41,11 @@ frame controls session page content =
div [ id "app" ]
[ header []
[ ul [ class "navbar", attribute "role" "navigation" ]
[ li [ id "navbar-brand" ] [ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
, navbarLink page Route.Monitor [ text "Monitor" ]
, navbarLink page Route.Status [ text "Status" ]
, navbarRecent page controls
[ li [ id "navbar-brand" ]
[ a [ Route.href session.key Route.Home ] [ text "@ inbucket" ] ]
, navbarLink session page Route.Monitor [ text "Monitor" ]
, navbarLink session page Route.Status [ text "Status" ]
, navbarRecent session page controls
, li [ id "navbar-mailbox" ]
[ form [ Events.onSubmit (controls.viewMailbox controls.mailboxValue) ]
[ input
@@ -61,33 +64,35 @@ frame controls session page content =
, content
, 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 "
, a [ href "https://github.com/jhillyerd/inbucket" ] [ text "GitHub" ]
, externalLink "https://github.com/jhillyerd/inbucket" "GitHub"
, text "."
]
]
]
navbarLink : ActivePage -> Route -> List (Html a) -> Html a
navbarLink page route linkContent =
externalLink : String -> String -> Html a
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 ) ] ]
[ a [ Route.href route ] linkContent ]
[ a [ Route.href session.key route ] linkContent ]
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
-}
navbarRecent : ActivePage -> FrameControls msg -> Html msg
navbarRecent page controls =
navbarRecent : Session -> ActivePage -> FrameControls msg -> Html msg
navbarRecent session page controls =
let
recentItemLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
active =
page == Mailbox
-- Navbar tab title, is current mailbox when active.
-- Recent tab title is the name of the current mailbox when active.
title =
if active then
controls.recentActive
@@ -95,20 +100,23 @@ navbarRecent page controls =
else
"Recent Mailboxes"
-- Items to show in recent list, doesn't include active mailbox.
items =
-- Mailboxes to show in recent list, doesn't include active mailbox.
recentMailboxes =
if active then
List.tail controls.recentOptions |> Maybe.withDefault []
else
controls.recentOptions
recentLink mailbox =
a [ Route.href session.key (Route.Mailbox mailbox) ] [ text mailbox ]
in
li
[ id "navbar-recent"
, classList [ ( "navbar-dropdown", True ), ( "navbar-active", active ) ]
]
[ 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 } from './Main.elm'
import registerServiceWorker from './registerServiceWorker'
import { Elm } from './Main.elm'
import registerMonitorPorts from './registerMonitor'
import './renderedHtml'
// App startup.
var app = Main.embed(document.getElementById('root'), sessionObject())
var app = Elm.Main.init({
node: document.getElementById('root'),
flags: sessionObject()
})
// Message monitor.
registerMonitorPorts(app)
@@ -31,10 +34,3 @@ function sessionObject() {
}
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
}