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:
@@ -124,9 +124,9 @@ func main() {
|
||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||
retentionScanner.Start()
|
||||
// Start HTTP server.
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
go web.Start(rootCtx)
|
||||
// Start POP3 server.
|
||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||
|
||||
@@ -6,13 +6,21 @@ 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_STORAGE_TYPE="file"
|
||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
||||
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||
|
||||
if ! test -x ./inbucket; then
|
||||
echo "$PWD/inbucket not found/executable!" >&2
|
||||
echo "Run this script from the inbucket root directory after running make" >&2
|
||||
echo "Run this script from the inbucket root directory after running make." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||
if ! test -f "$index"; then
|
||||
echo "$index does not exist!" >&2
|
||||
echo "Run 'elm-app build' from the 'ui' directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
},
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ func Reverse(name string, things ...interface{}) string {
|
||||
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func TextToHTML(text string) template.HTML {
|
||||
func TextToHTML(text string) string {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(replacer.Replace(text))
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextToHtml(t *testing.T) {
|
||||
// Identity
|
||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
||||
|
||||
// Check it escapes
|
||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
||||
|
||||
// Check for linebreaks
|
||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
testCases := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{
|
||||
input: "html",
|
||||
want: "html",
|
||||
},
|
||||
// Check it escapes.
|
||||
{
|
||||
input: "<html>",
|
||||
want: "<html>",
|
||||
},
|
||||
// Check for linebreaks.
|
||||
{
|
||||
input: "line\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\r\nbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
{
|
||||
input: "line\rbreak",
|
||||
want: "line<br/>\nbreak",
|
||||
},
|
||||
// Check URL detection.
|
||||
{
|
||||
input: "http://google.com/",
|
||||
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||
},
|
||||
{
|
||||
input: "http://a.com/?q=a&n=v",
|
||||
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||
},
|
||||
{
|
||||
input: "(http://a.com/?q=a&n=v)",
|
||||
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := TextToHTML(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestURLDetection(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://google.com/"),
|
||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://a.com/?q=a&n=v"),
|
||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -66,11 +65,8 @@ func Initialize(
|
||||
manager = mm
|
||||
|
||||
// Content Paths
|
||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||
Msg("Web UI content mapped")
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(staticPath))))
|
||||
Router.Handle("/debug/vars", expvar.Handler())
|
||||
if conf.Web.PProf {
|
||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
@@ -81,6 +77,8 @@ 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))))
|
||||
|
||||
// Session cookie setup
|
||||
if conf.Web.CookieAuthKey == "" {
|
||||
|
||||
@@ -6,138 +6,87 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// MailboxIndex renders the index page for a particular mailbox
|
||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Form values must be validated manually
|
||||
name := req.FormValue("name")
|
||||
selected := req.FormValue("id")
|
||||
if len(name) == 0 {
|
||||
ctx.Session.AddFlash("Account name is required", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
name, err = ctx.Manager.MailboxForAddress(name)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Remember this mailbox was visited
|
||||
RememberMailbox(ctx, name)
|
||||
// Get flash messages, save session
|
||||
errorFlash := ctx.Session.Flashes("errors")
|
||||
if err = ctx.Session.Save(req, w); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render template
|
||||
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"errorFlash": errorFlash,
|
||||
"name": name,
|
||||
"selected": selected,
|
||||
})
|
||||
// JSONMessage formats message data for the UI.
|
||||
type JSONMessage struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
Seen bool `json:"seen"`
|
||||
Header map[string][]string `json:"header"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
Attachments []*JSONAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
|
||||
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
// JSONAttachment formats attachment data for the UI.
|
||||
type JSONAttachment struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"filename"`
|
||||
ContentType string `json:"content-type"`
|
||||
}
|
||||
|
||||
// MailboxLink handles pretty links to a particular message. Renders a redirect
|
||||
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Build redirect
|
||||
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
|
||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxList renders a list of messages in a mailbox. Renders a partial
|
||||
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages, err := ctx.Manager.GetMetadata(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"messages": messages,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
|
||||
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
if err != nil && err != storage.ErrNotExist {
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if msg == nil {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
attachParts := msg.Attachments()
|
||||
attachments := make([]*JSONAttachment, len(attachParts))
|
||||
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()))
|
||||
htmlAvailable := msg.HTML() != ""
|
||||
var htmlBody template.HTML
|
||||
if htmlAvailable {
|
||||
}
|
||||
// Sanitize HTML body.
|
||||
htmlBody := ""
|
||||
if msg.HTML() != "" {
|
||||
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||
htmlBody = template.HTML(str)
|
||||
htmlBody = str
|
||||
} else {
|
||||
// Soft failure, render empty tab.
|
||||
htmlBody = "Inbucket HTML sanitizer failed."
|
||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||
Msg("HTML sanitizer failed")
|
||||
}
|
||||
}
|
||||
// Render partial template
|
||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
||||
"ctx": ctx,
|
||||
"name": name,
|
||||
"message": msg,
|
||||
"body": body,
|
||||
"htmlAvailable": htmlAvailable,
|
||||
"htmlBody": htmlBody,
|
||||
"mimeErrors": msg.MIMEErrors(),
|
||||
"attachments": msg.Attachments(),
|
||||
return web.RenderJSON(w,
|
||||
&JSONMessage{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: msg.From.String(),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
Seen: msg.Seen,
|
||||
Header: msg.Header(),
|
||||
Text: web.TextToHTML(msg.Text()),
|
||||
HTML: htmlBody,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,48 +140,6 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxDownloadAttach sends the attachment to the client; disposition:
|
||||
// attachment, type: application/octet-stream
|
||||
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash(err.Error(), "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
numStr := ctx.Vars["num"]
|
||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||
if err != nil {
|
||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
msg, err := ctx.Manager.GetMessage(name, id)
|
||||
if err == storage.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
if int(num) >= len(msg.Attachments()) {
|
||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
||||
_ = ctx.Session.Save(req, w)
|
||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
// Output attachment
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
_, err = w.Write(msg.Attachments()[num].Content)
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"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) {
|
||||
r.Path("/").Handler(
|
||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
||||
@@ -16,22 +16,12 @@ func SetupRoutes(r *mux.Router) {
|
||||
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
||||
r.Path("/status").Handler(
|
||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
||||
r.Path("/link/{name}/{id}").Handler(
|
||||
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
|
||||
r.Path("/mailbox").Handler(
|
||||
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
|
||||
r.Path("/mailbox/{name}").Handler(
|
||||
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}").Handler(
|
||||
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
|
||||
r.Path("/mailbox/{name}/{id}/html").Handler(
|
||||
r.Path("/m/{name}/{id}").Handler(
|
||||
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
|
||||
r.Path("/m/{name}/{id}/html").Handler(
|
||||
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")
|
||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
||||
r.Path("/m/attach/{name}/{id}/{num}/{file}").Handler(
|
||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
||||
r.Path("/{name}").Handler(
|
||||
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
},
|
||||
"/debug": {
|
||||
"target": "http://localhost:9000"
|
||||
},
|
||||
"/serve": {
|
||||
"target": "http://localhost:9000"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -10,8 +10,8 @@
|
||||
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.ico">
|
||||
<title>Elm App</title>
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" type="image/png">
|
||||
<title>Inbucket</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
@@ -13,13 +13,16 @@ type alias Message =
|
||||
, date : String
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
, body : Body
|
||||
, text : String
|
||||
, html : String
|
||||
, attachments : List Attachment
|
||||
}
|
||||
|
||||
|
||||
type alias Body =
|
||||
{ text : String
|
||||
, html : String
|
||||
type alias Attachment =
|
||||
{ id : String
|
||||
, fileName : String
|
||||
, contentType : String
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +37,14 @@ decoder =
|
||||
|> required "date" string
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
|> required "body" bodyDecoder
|
||||
|
||||
|
||||
bodyDecoder : Decoder Body
|
||||
bodyDecoder =
|
||||
decode Body
|
||||
|> required "text" string
|
||||
|> required "html" string
|
||||
|> required "attachments" (list attachmentDecoder)
|
||||
|
||||
|
||||
attachmentDecoder : Decoder Attachment
|
||||
attachmentDecoder =
|
||||
decode Attachment
|
||||
|> required "id" string
|
||||
|> required "filename" string
|
||||
|> required "content-type" string
|
||||
|
||||
@@ -12,10 +12,12 @@ module Data.Session
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Navigation exposing (Location)
|
||||
|
||||
|
||||
type alias Session =
|
||||
{ flash : String
|
||||
{ host : String
|
||||
, flash : String
|
||||
, routing : Bool
|
||||
, persistent : Persistent
|
||||
}
|
||||
@@ -35,9 +37,9 @@ type Msg
|
||||
| AddRecent String
|
||||
|
||||
|
||||
init : Persistent -> Session
|
||||
init persistent =
|
||||
Session "" True persistent
|
||||
init : Location -> Persistent -> Session
|
||||
init location persistent =
|
||||
Session location.host "" True persistent
|
||||
|
||||
|
||||
update : Msg -> Session -> Session
|
||||
|
||||
@@ -34,7 +34,7 @@ init : Value -> Location -> ( Model, Cmd Msg )
|
||||
init sessionValue location =
|
||||
let
|
||||
session =
|
||||
Session.init (Session.decodeValueWithDefault sessionValue)
|
||||
Session.init location (Session.decodeValueWithDefault sessionValue)
|
||||
|
||||
model =
|
||||
{ page = Home Home.init
|
||||
@@ -197,7 +197,7 @@ setRoute route model =
|
||||
)
|
||||
|
||||
Route.Monitor ->
|
||||
( { model | page = Monitor Monitor.init }
|
||||
( { model | page = Monitor (Monitor.init model.session.host) }
|
||||
, Ports.windowTitle "Inbucket Monitor"
|
||||
, Session.none
|
||||
)
|
||||
|
||||
@@ -5,21 +5,21 @@ import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
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 Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Json.Encode exposing (string)
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
|
||||
|
||||
inbucketBase : String
|
||||
inbucketBase =
|
||||
""
|
||||
-- MODEL
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
type Body
|
||||
= TextBody
|
||||
| SafeHtmlBody
|
||||
|
||||
|
||||
type alias Model =
|
||||
@@ -27,12 +27,13 @@ type alias Model =
|
||||
, selected : Maybe String
|
||||
, headers : List MessageHeader
|
||||
, message : Maybe Message
|
||||
, bodyMode : Body
|
||||
}
|
||||
|
||||
|
||||
init : String -> Maybe String -> Model
|
||||
init name id =
|
||||
Model name id [] Nothing
|
||||
Model name id [] Nothing SafeHtmlBody
|
||||
|
||||
|
||||
load : String -> Cmd Msg
|
||||
@@ -44,7 +45,7 @@ load name =
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
@@ -52,8 +53,9 @@ type Msg
|
||||
| ViewMessage String
|
||||
| DeleteMessage Message
|
||||
| DeleteMessageResult (Result Http.Error ())
|
||||
| NewMailbox (Result Http.Error (List MessageHeader))
|
||||
| NewMessage (Result Http.Error Message)
|
||||
| MailboxResult (Result Http.Error (List MessageHeader))
|
||||
| MessageResult (Result Http.Error Message)
|
||||
| MessageBody Body
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
@@ -83,7 +85,7 @@ update session msg model =
|
||||
DeleteMessageResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMailbox (Ok headers) ->
|
||||
MailboxResult (Ok headers) ->
|
||||
let
|
||||
newModel =
|
||||
{ model | headers = headers }
|
||||
@@ -96,31 +98,47 @@ update session msg model =
|
||||
-- Recurse to select message id.
|
||||
update session (ViewMessage id) newModel
|
||||
|
||||
NewMailbox (Err err) ->
|
||||
MailboxResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMessage (Ok msg) ->
|
||||
( { model | message = Just msg }, Cmd.none, Session.none )
|
||||
MessageResult (Ok msg) ->
|
||||
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) )
|
||||
|
||||
MessageBody bodyMode ->
|
||||
( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
|
||||
|
||||
|
||||
getMailbox : String -> Cmd Msg
|
||||
getMailbox name =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ name
|
||||
"/api/v1/mailbox/" ++ name
|
||||
in
|
||||
Http.get url (Decode.list MessageHeader.decoder)
|
||||
|> Http.send NewMailbox
|
||||
|> Http.send MailboxResult
|
||||
|
||||
|
||||
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
|
||||
deleteMessage model msg =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||
"/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||
|
||||
cmd =
|
||||
HttpUtil.delete url
|
||||
@@ -140,35 +158,46 @@ getMessage : String -> String -> Cmd Msg
|
||||
getMessage mailbox id =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ mailbox ++ "/" ++ id
|
||||
"/serve/m/" ++ mailbox ++ "/" ++ id
|
||||
in
|
||||
Http.get url Message.decoder
|
||||
|> Http.send NewMessage
|
||||
|> Http.send MessageResult
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page", class "mailbox" ]
|
||||
[ aside [ id "message-list" ] [ viewMailbox model ]
|
||||
, main_ [ id "message" ] [ viewMessage model ]
|
||||
[ aside [ id "message-list" ] [ messageList 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
|
||||
viewMailbox model =
|
||||
div [] (List.map (viewHeader model) (List.reverse model.headers))
|
||||
messageList : Model -> Html Msg
|
||||
messageList model =
|
||||
div [] (List.map (messageChip model.selected) (List.reverse model.headers))
|
||||
|
||||
|
||||
viewHeader : Model -> MessageHeader -> Html Msg
|
||||
viewHeader mailbox msg =
|
||||
messageChip : Maybe String -> MessageHeader -> Html Msg
|
||||
messageChip selected msg =
|
||||
div
|
||||
[ classList
|
||||
[ ( "message-list-entry", True )
|
||||
, ( "selected", mailbox.selected == Just msg.id )
|
||||
, ( "selected", selected == Just msg.id )
|
||||
, ( "unseen", not msg.seen )
|
||||
]
|
||||
, onClick (ClickMessage msg.id)
|
||||
@@ -179,24 +208,17 @@ viewHeader mailbox msg =
|
||||
]
|
||||
|
||||
|
||||
viewMessage : Model -> Html Msg
|
||||
viewMessage model =
|
||||
case model.message of
|
||||
Just message ->
|
||||
viewMessage : Message -> Body -> Html Msg
|
||||
viewMessage message bodyMode =
|
||||
let
|
||||
sourceUrl message =
|
||||
"/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
|
||||
in
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a
|
||||
[ href
|
||||
(inbucketBase
|
||||
++ "/mailbox/"
|
||||
++ message.mailbox
|
||||
++ "/"
|
||||
++ message.id
|
||||
++ "/source"
|
||||
)
|
||||
, target "_blank"
|
||||
]
|
||||
[ href (sourceUrl message), target "_blank" ]
|
||||
[ button [] [ text "Source" ] ]
|
||||
]
|
||||
, dl [ id "message-header" ]
|
||||
@@ -209,8 +231,69 @@ viewMessage model =
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, article [] [ text message.body.text ]
|
||||
, 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" ] ]
|
||||
]
|
||||
|
||||
@@ -10,30 +10,34 @@ import Route
|
||||
import WebSocket
|
||||
|
||||
|
||||
-- MODEL --
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ messages : List MessageHeader }
|
||||
{ wsUrl : String
|
||||
, messages : List MessageHeader
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ messages = [] }
|
||||
init : String -> Model
|
||||
init host =
|
||||
{ wsUrl = "ws://" ++ host ++ "/api/v1/monitor/messages"
|
||||
, messages = []
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
WebSocket.listen "ws://192.168.1.10:3000/api/v1/monitor/messages"
|
||||
WebSocket.listen model.wsUrl
|
||||
(decodeString MessageHeader.decoder >> NewMessage)
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
@@ -58,7 +62,7 @@ update session msg model =
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
|
||||
@@ -30,6 +30,11 @@ time, mark, audio, video {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #337ab7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
@@ -46,6 +51,33 @@ body, input, table {
|
||||
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 {
|
||||
@@ -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 */
|
||||
|
||||
.metric-panel {
|
||||
|
||||
Reference in New Issue
Block a user