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.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)

View File

@@ -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

View File

@@ -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
}

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
// 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

View File

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

View File

@@ -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 == "" {

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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

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/
-->
<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>

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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" ] ]
]

View File

@@ -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

View File

@@ -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 {