1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 09:37:02 +00:00

ui: Much elm work, such wow

- ui: Fix favicon
- webui: Changes to support serving Elm UI
- Static files now served from `/` mount point.
- Old UI handlers moved to `/serve` mount point, some will still be
  needed by the Elm UI; safe HTML and attachments for example.
- Update dev-start.sh for new UI, with tip on how to build it.
- ui: Detect browser host:port for websocket URL,
- webui: Remove unused mailbox handlers, rename routes
- Many routes not needed by Elm UI.
- `/serve/mailbox/*` becomes `/serve/m/*`.
- webui: Impl custom JSON message API for web UI,
- ui: Refactor Mailbox view functions,
- ui: Add body tabs for safe HTML and plain text,
- webui: Format plain text for new UI,
- ui: List attachments with view & download links,
This commit is contained in:
James Hillyerd
2018-06-02 12:53:24 -07:00
parent c5b5321be3
commit dd14fb9989
18 changed files with 384 additions and 286 deletions

View File

@@ -124,9 +124,9 @@ func main() {
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan) retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start() retentionScanner.Start()
// Start HTTP server. // Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub) webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router) web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx) go web.Start(rootCtx)
// Start POP3 server. // Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store) pop3Server := pop3.New(conf.POP3, shutdownChan, store)

View File

@@ -6,13 +6,21 @@ 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_STORAGE_TYPE="file" export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="15m" export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
if ! test -x ./inbucket; then if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2 echo "$PWD/inbucket not found/executable!" >&2
echo "Run this script from the inbucket root directory after running make" >&2 echo "Run this script from the inbucket root directory after running make." >&2
exit 1
fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'elm-app build' from the 'ui' directory." >&2
exit 1 exit 1
fi fi

View File

@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
}, },
} }
shutdownChan := make(chan bool) shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
return buf return buf
} }

View File

@@ -54,11 +54,11 @@ func Reverse(name string, things ...interface{}) string {
// TextToHTML takes plain text, escapes it and tries to pretty it up for // TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display // HTML display
func TextToHTML(text string) template.HTML { func TextToHTML(text string) string {
text = html.EscapeString(text) text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL) text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n") replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text)) return replacer.Replace(text)
} }
// WrapURL wraps a <a href> tag around the provided URL // WrapURL wraps a <a href> tag around the provided URL

View File

@@ -1,30 +1,55 @@
package web package web
import ( import (
"html/template"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestTextToHtml(t *testing.T) { func TestTextToHtml(t *testing.T) {
// Identity testCases := []struct {
assert.Equal(t, TextToHTML("html"), template.HTML("html")) input, want string
}{
// Check it escapes {
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;")) input: "html",
want: "html",
// Check for linebreaks },
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak")) // Check it escapes.
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak")) {
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak")) input: "<html>",
} want: "&lt;html&gt;",
},
func TestURLDetection(t *testing.T) { // Check for linebreaks.
assert.Equal(t, {
TextToHTML("http://google.com/"), input: "line\nbreak",
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>")) want: "line<br/>\nbreak",
assert.Equal(t, },
TextToHTML("http://a.com/?q=a&n=v"), {
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>")) input: "line\r\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\rbreak",
want: "line<br/>\nbreak",
},
// Check URL detection.
{
input: "http://google.com/",
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
},
{
input: "http://a.com/?q=a&n=v",
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>",
},
{
input: "(http://a.com/?q=a&n=v)",
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>)",
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got := TextToHTML(tc.input)
if got != tc.want {
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
}
})
}
} }

View File

@@ -7,7 +7,6 @@ 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"
@@ -66,11 +65,8 @@ func Initialize(
manager = mm manager = mm
// Content Paths // Content Paths
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped") Msg("Web UI content mapped")
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(staticPath))))
Router.Handle("/debug/vars", expvar.Handler()) Router.Handle("/debug/vars", expvar.Handler())
if conf.Web.PProf { if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
@@ -81,6 +77,8 @@ 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))))
// Session cookie setup // Session cookie setup
if conf.Web.CookieAuthKey == "" { if conf.Web.CookieAuthKey == "" {

View File

@@ -6,139 +6,88 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/jhillyerd/inbucket/pkg/server/web" "github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage" "github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize" "github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// MailboxIndex renders the index page for a particular mailbox // JSONMessage formats message data for the UI.
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { type JSONMessage struct {
// Form values must be validated manually Mailbox string `json:"mailbox"`
name := req.FormValue("name") ID string `json:"id"`
selected := req.FormValue("id") From string `json:"from"`
if len(name) == 0 { To []string `json:"to"`
ctx.Session.AddFlash("Account name is required", "errors") Subject string `json:"subject"`
_ = ctx.Session.Save(req, w) Date time.Time `json:"date"`
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) Size int64 `json:"size"`
return nil Seen bool `json:"seen"`
} Header map[string][]string `json:"header"`
name, err = ctx.Manager.MailboxForAddress(name) Text string `json:"text"`
if err != nil { HTML string `json:"html"`
ctx.Session.AddFlash(err.Error(), "errors") Attachments []*JSONAttachment `json:"attachments"`
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Remember this mailbox was visited
RememberMailbox(ctx, name)
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
"selected": selected,
})
} }
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect // JSONAttachment formats attachment data for the UI.
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { type JSONAttachment struct {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) ID string `json:"id"`
if err != nil { FileName string `json:"filename"`
ctx.Session.AddFlash(err.Error(), "errors") ContentType string `json:"content-type"`
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
} }
// MailboxLink handles pretty links to a particular message. Renders a redirect // MailboxMessage outputs a particular message as JSON for the UI.
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxList renders a list of messages in a mailbox. Renders a partial
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
// Render partial template
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"messages": messages,
})
}
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil { if err != nil {
return err return err
} }
msg, err := ctx.Manager.GetMessage(name, id) msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist { if err != nil && err != storage.ErrNotExist {
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if msg == nil {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
if err != nil { attachParts := msg.Attachments()
// This doesn't indicate empty, likely an IO error attachments := make([]*JSONAttachment, len(attachParts))
return fmt.Errorf("GetMessage(%q) failed: %v", id, err) for i, part := range attachParts {
attachments[i] = &JSONAttachment{
ID: strconv.Itoa(i),
FileName: part.FileName,
ContentType: part.ContentType,
}
} }
body := template.HTML(web.TextToHTML(msg.Text())) // Sanitize HTML body.
htmlAvailable := msg.HTML() != "" htmlBody := ""
var htmlBody template.HTML if msg.HTML() != "" {
if htmlAvailable {
if str, err := sanitize.HTML(msg.HTML()); err == nil { if str, err := sanitize.HTML(msg.HTML()); err == nil {
htmlBody = template.HTML(str) htmlBody = str
} else { } else {
// Soft failure, render empty tab. htmlBody = "Inbucket HTML sanitizer failed."
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err). log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
Msg("HTML sanitizer failed") Msg("HTML sanitizer failed")
} }
} }
// Render partial template return web.RenderJSON(w,
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ &JSONMessage{
"ctx": ctx, Mailbox: name,
"name": name, ID: msg.ID,
"message": msg, From: msg.From.String(),
"body": body, To: stringutil.StringAddressList(msg.To),
"htmlAvailable": htmlAvailable, Subject: msg.Subject,
"htmlBody": htmlBody, Date: msg.Date,
"mimeErrors": msg.MIMEErrors(), Size: msg.Size,
"attachments": msg.Attachments(), Seen: msg.Seen,
}) Header: msg.Header(),
Text: web.TextToHTML(msg.Text()),
HTML: htmlBody,
Attachments: attachments,
})
} }
// MailboxHTML displays the HTML content of a message. Renders a partial // MailboxHTML displays the HTML content of a message. Renders a partial
@@ -191,48 +140,6 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
return err return err
} }
// MailboxDownloadAttach sends the attachment to the client; disposition:
// attachment, type: application/octet-stream
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
_, err = w.Write(msg.Attachments()[num].Content)
return err
}
// MailboxViewAttach sends the attachment to the client for online viewing // MailboxViewAttach sends the attachment to the client for online viewing
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404 // Don't have to validate these aren't empty, Gorilla returns 404

View File

@@ -6,7 +6,7 @@ import (
"github.com/jhillyerd/inbucket/pkg/server/web" "github.com/jhillyerd/inbucket/pkg/server/web"
) )
// SetupRoutes populates routes for the webui into the provided Router // SetupRoutes populates routes for the webui into the provided Router.
func SetupRoutes(r *mux.Router) { func SetupRoutes(r *mux.Router) {
r.Path("/").Handler( r.Path("/").Handler(
web.Handler(RootIndex)).Name("RootIndex").Methods("GET") web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
@@ -16,22 +16,12 @@ func SetupRoutes(r *mux.Router) {
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET") web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
r.Path("/status").Handler( r.Path("/status").Handler(
web.Handler(RootStatus)).Name("RootStatus").Methods("GET") web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
r.Path("/link/{name}/{id}").Handler( r.Path("/m/{name}/{id}").Handler(
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET") web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
r.Path("/mailbox").Handler( r.Path("/m/{name}/{id}/html").Handler(
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
r.Path("/mailbox/{name}").Handler(
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
r.Path("/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
r.Path("/mailbox/{name}/{id}/html").Handler(
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET") web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
r.Path("/mailbox/{name}/{id}/source").Handler( r.Path("/m/{name}/{id}/source").Handler(
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET") web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler( r.Path("/m/attach/{name}/{id}/{num}/{file}").Handler(
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET") web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
r.Path("/{name}").Handler(
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
} }

View File

@@ -14,6 +14,9 @@
}, },
"/debug": { "/debug": {
"target": "http://localhost:9000" "target": "http://localhost:9000"
},
"/serve": {
"target": "http://localhost:9000"
} }
}, },
"dependencies": { "dependencies": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -10,8 +10,8 @@
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ 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="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" type="image/png">
<title>Elm App</title> <title>Inbucket</title>
</head> </head>
<body> <body>
<noscript> <noscript>

View File

@@ -13,13 +13,16 @@ type alias Message =
, date : String , date : String
, size : Int , size : Int
, seen : Bool , seen : Bool
, body : Body , text : String
, html : String
, attachments : List Attachment
} }
type alias Body = type alias Attachment =
{ text : String { id : String
, html : String , fileName : String
, contentType : String
} }
@@ -34,11 +37,14 @@ decoder =
|> required "date" string |> required "date" string
|> required "size" int |> required "size" int
|> required "seen" bool |> required "seen" bool
|> required "body" bodyDecoder
bodyDecoder : Decoder Body
bodyDecoder =
decode Body
|> required "text" string |> required "text" string
|> required "html" string |> required "html" string
|> required "attachments" (list attachmentDecoder)
attachmentDecoder : Decoder Attachment
attachmentDecoder =
decode Attachment
|> required "id" string
|> required "filename" string
|> required "content-type" string

View File

@@ -12,10 +12,12 @@ module Data.Session
import Json.Decode as Decode exposing (..) import Json.Decode as Decode exposing (..)
import Json.Decode.Pipeline exposing (..) import Json.Decode.Pipeline exposing (..)
import Navigation exposing (Location)
type alias Session = type alias Session =
{ flash : String { host : String
, flash : String
, routing : Bool , routing : Bool
, persistent : Persistent , persistent : Persistent
} }
@@ -35,9 +37,9 @@ type Msg
| AddRecent String | AddRecent String
init : Persistent -> Session init : Location -> Persistent -> Session
init persistent = init location persistent =
Session "" True persistent Session location.host "" True persistent
update : Msg -> Session -> Session update : Msg -> Session -> Session

View File

@@ -34,7 +34,7 @@ init : Value -> Location -> ( Model, Cmd Msg )
init sessionValue location = init sessionValue location =
let let
session = session =
Session.init (Session.decodeValueWithDefault sessionValue) Session.init location (Session.decodeValueWithDefault sessionValue)
model = model =
{ page = Home Home.init { page = Home Home.init
@@ -197,7 +197,7 @@ setRoute route model =
) )
Route.Monitor -> Route.Monitor ->
( { model | page = Monitor Monitor.init } ( { model | page = Monitor (Monitor.init model.session.host) }
, Ports.windowTitle "Inbucket Monitor" , Ports.windowTitle "Inbucket Monitor"
, Session.none , Session.none
) )

View File

@@ -5,21 +5,21 @@ import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (class, classList, href, id, placeholder, target) import Html.Attributes exposing (class, classList, downloadAs, href, id, property, target)
import Html.Events exposing (..) import Html.Events exposing (..)
import Http exposing (Error) import Http exposing (Error)
import HttpUtil import HttpUtil
import Json.Encode exposing (string)
import Ports import Ports
import Route exposing (Route) import Route exposing (Route)
inbucketBase : String -- MODEL
inbucketBase =
""
type Body
-- MODEL -- = TextBody
| SafeHtmlBody
type alias Model = type alias Model =
@@ -27,12 +27,13 @@ type alias Model =
, selected : Maybe String , selected : Maybe String
, headers : List MessageHeader , headers : List MessageHeader
, message : Maybe Message , message : Maybe Message
, bodyMode : Body
} }
init : String -> Maybe String -> Model init : String -> Maybe String -> Model
init name id = init name id =
Model name id [] Nothing Model name id [] Nothing SafeHtmlBody
load : String -> Cmd Msg load : String -> Cmd Msg
@@ -44,7 +45,7 @@ load name =
-- UPDATE -- -- UPDATE
type Msg type Msg
@@ -52,8 +53,9 @@ type Msg
| ViewMessage String | ViewMessage String
| DeleteMessage Message | DeleteMessage Message
| DeleteMessageResult (Result Http.Error ()) | DeleteMessageResult (Result Http.Error ())
| NewMailbox (Result Http.Error (List MessageHeader)) | MailboxResult (Result Http.Error (List MessageHeader))
| NewMessage (Result Http.Error Message) | MessageResult (Result Http.Error Message)
| MessageBody Body
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg ) update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
@@ -83,7 +85,7 @@ update session msg model =
DeleteMessageResult (Err err) -> DeleteMessageResult (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
NewMailbox (Ok headers) -> MailboxResult (Ok headers) ->
let let
newModel = newModel =
{ model | headers = headers } { model | headers = headers }
@@ -96,31 +98,47 @@ update session msg model =
-- Recurse to select message id. -- Recurse to select message id.
update session (ViewMessage id) newModel update session (ViewMessage id) newModel
NewMailbox (Err err) -> MailboxResult (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
NewMessage (Ok msg) -> MessageResult (Ok msg) ->
( { model | message = Just msg }, Cmd.none, Session.none ) let
bodyMode =
if msg.html == "" then
TextBody
else
model.bodyMode
in
( { model
| message = Just msg
, bodyMode = bodyMode
}
, Cmd.none
, Session.none
)
NewMessage (Err err) -> MessageResult (Err err) ->
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) ) ( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
getMailbox : String -> Cmd Msg getMailbox : String -> Cmd Msg
getMailbox name = getMailbox name =
let let
url = url =
inbucketBase ++ "/api/v1/mailbox/" ++ name "/api/v1/mailbox/" ++ name
in in
Http.get url (Decode.list MessageHeader.decoder) Http.get url (Decode.list MessageHeader.decoder)
|> Http.send NewMailbox |> Http.send MailboxResult
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg ) deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
deleteMessage model msg = deleteMessage model msg =
let let
url = url =
inbucketBase ++ "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
cmd = cmd =
HttpUtil.delete url HttpUtil.delete url
@@ -140,35 +158,46 @@ getMessage : String -> String -> Cmd Msg
getMessage mailbox id = getMessage mailbox id =
let let
url = url =
inbucketBase ++ "/api/v1/mailbox/" ++ mailbox ++ "/" ++ id "/serve/m/" ++ mailbox ++ "/" ++ id
in in
Http.get url Message.decoder Http.get url Message.decoder
|> Http.send NewMessage |> Http.send MessageResult
-- VIEW -- -- VIEW
view : Session -> Model -> Html Msg view : Session -> Model -> Html Msg
view session model = view session model =
div [ id "page", class "mailbox" ] div [ id "page", class "mailbox" ]
[ aside [ id "message-list" ] [ viewMailbox model ] [ aside [ id "message-list" ] [ messageList model ]
, main_ [ id "message" ] [ viewMessage model ] , main_
[ id "message" ]
[ case model.message of
Just message ->
viewMessage message model.bodyMode
Nothing ->
text
("Select a message on the left,"
++ " or enter a different username into the box on upper right."
)
]
] ]
viewMailbox : Model -> Html Msg messageList : Model -> Html Msg
viewMailbox model = messageList model =
div [] (List.map (viewHeader model) (List.reverse model.headers)) div [] (List.map (messageChip model.selected) (List.reverse model.headers))
viewHeader : Model -> MessageHeader -> Html Msg messageChip : Maybe String -> MessageHeader -> Html Msg
viewHeader mailbox msg = messageChip selected msg =
div div
[ classList [ classList
[ ( "message-list-entry", True ) [ ( "message-list-entry", True )
, ( "selected", mailbox.selected == Just msg.id ) , ( "selected", selected == Just msg.id )
, ( "unseen", not msg.seen ) , ( "unseen", not msg.seen )
] ]
, onClick (ClickMessage msg.id) , onClick (ClickMessage msg.id)
@@ -179,38 +208,92 @@ viewHeader mailbox msg =
] ]
viewMessage : Model -> Html Msg viewMessage : Message -> Body -> Html Msg
viewMessage model = viewMessage message bodyMode =
case model.message of let
Just message -> sourceUrl message =
div [] "/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
[ div [ class "button-bar" ] in
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ] div []
, a [ div [ class "button-bar" ]
[ href [ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
(inbucketBase , a
++ "/mailbox/" [ href (sourceUrl message), target "_blank" ]
++ message.mailbox [ button [] [ text "Source" ] ]
++ "/"
++ message.id
++ "/source"
)
, target "_blank"
]
[ button [] [ text "Source" ] ]
]
, dl [ id "message-header" ]
[ dt [] [ text "From:" ]
, dd [] [ text message.from ]
, dt [] [ text "To:" ]
, dd [] (List.map text message.to)
, dt [] [ text "Date:" ]
, dd [] [ text message.date ]
, dt [] [ text "Subject:" ]
, dd [] [ text message.subject ]
]
, article [] [ text message.body.text ]
] ]
, dl [ id "message-header" ]
[ dt [] [ text "From:" ]
, dd [] [ text message.from ]
, dt [] [ text "To:" ]
, dd [] (List.map text message.to)
, dt [] [ text "Date:" ]
, dd [] [ text message.date ]
, dt [] [ text "Subject:" ]
, dd [] [ text message.subject ]
]
, messageBody message bodyMode
, attachments message
]
Nothing ->
text "" messageBody : Message -> Body -> Html Msg
messageBody message bodyMode =
let
bodyModeTab mode label =
a
[ classList [ ( "active", bodyMode == mode ) ]
, onClick (MessageBody mode)
, href "javacript:void(0)"
]
[ text label ]
safeHtml =
bodyModeTab SafeHtmlBody "Safe HTML"
plainText =
bodyModeTab TextBody "Plain Text"
tabs =
if message.html == "" then
[ plainText ]
else
[ safeHtml, plainText ]
in
div [ class "tab-panel" ]
[ nav [ class "tab-bar" ] tabs
, article [ class "message-body" ]
[ case bodyMode of
SafeHtmlBody ->
div [ property "innerHTML" (string message.html) ] []
TextBody ->
div [ property "innerHTML" (string message.text) ] []
]
]
attachments : Message -> Html Msg
attachments message =
let
baseUrl =
"/serve/m/attach/" ++ message.mailbox ++ "/" ++ message.id ++ "/"
in
if List.isEmpty message.attachments then
div [] []
else
table [ class "attachments well" ] (List.map (attachmentRow baseUrl) message.attachments)
attachmentRow : String -> Message.Attachment -> Html Msg
attachmentRow baseUrl attach =
let
url =
baseUrl ++ attach.id ++ "/" ++ attach.fileName
in
tr []
[ td []
[ a [ href url, target "_blank" ] [ text attach.fileName ]
, text (" (" ++ attach.contentType ++ ") ")
]
, td [] [ a [ href url, downloadAs attach.fileName, class "button" ] [ text "Download" ] ]
]

View File

@@ -10,30 +10,34 @@ import Route
import WebSocket import WebSocket
-- MODEL -- -- MODEL
type alias Model = type alias Model =
{ messages : List MessageHeader } { wsUrl : String
, messages : List MessageHeader
}
init : Model init : String -> Model
init = init host =
{ messages = [] } { wsUrl = "ws://" ++ host ++ "/api/v1/monitor/messages"
, messages = []
}
-- SUBSCRIPTIONS -- -- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions model = subscriptions model =
WebSocket.listen "ws://192.168.1.10:3000/api/v1/monitor/messages" WebSocket.listen model.wsUrl
(decodeString MessageHeader.decoder >> NewMessage) (decodeString MessageHeader.decoder >> NewMessage)
-- UPDATE -- -- UPDATE
type Msg type Msg
@@ -58,7 +62,7 @@ update session msg model =
-- VIEW -- -- VIEW
view : Session -> Model -> Html Msg view : Session -> Model -> Html Msg

View File

@@ -30,6 +30,11 @@ time, mark, audio, video {
vertical-align: baseline; vertical-align: baseline;
} }
a {
color: #337ab7;
text-decoration: none;
}
body { body {
background-color: var(--bg-color); background-color: var(--bg-color);
} }
@@ -46,6 +51,33 @@ body, input, table {
opacity: 1; opacity: 1;
} }
/** SHARED */
a.button {
background-color: #337ab7;
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
border: none;
border-radius: 4px;
color: #fff;
display: inline-block;
font-size: 11px;
font-style: normal;
margin: 4px;
padding: 3px 8px;
text-decoration: none;
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
}
.well {
background-color: var(--selected-color);
background-image: linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
padding: 4px 10px;
margin: 20px 0;
}
/** APP */ /** APP */
#app { #app {
@@ -313,6 +345,46 @@ li.navbar-active span,
} }
} }
.message-body {
padding: 5px;
}
nav.tab-bar {
border-bottom: 1px solid var(--border-color);
display: flex;
margin: 20px 0 10px 0;
}
nav.tab-bar a {
border-radius: 4px 4px 0 0;
display: block;
margin-bottom: -1px;
margin-right: 2px;
padding: 8px 15px;
text-decoration: none;
}
nav.tab-bar a.active {
color: var(--low-color);
border-color: var(--border-color) var(--border-color) var(--bg-color) var(--border-color);
border-style: solid;
border-width: 1px;
}
nav.tab-bar a:focus,
nav.tab-bar a:hover {
background-color: var(--selected-color);
}
nav.tab-bar a.active:focus,
nav.tab-bar a.active:hover {
background-color: var(--bg-color);
}
.attachments {
width: 100%;
}
/** STATUS */ /** STATUS */
.metric-panel { .metric-panel {