diff --git a/cmd/inbucket/main.go b/cmd/inbucket/main.go
index ba37c09..b65c9ad 100644
--- a/cmd/inbucket/main.go
+++ b/cmd/inbucket/main.go
@@ -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)
diff --git a/etc/dev-start.sh b/etc/dev-start.sh
index a35db19..995edde 100755
--- a/etc/dev-start.sh
+++ b/etc/dev-start.sh
@@ -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
diff --git a/pkg/rest/testutils_test.go b/pkg/rest/testutils_test.go
index 520532c..0df7eec 100644
--- a/pkg/rest/testutils_test.go
+++ b/pkg/rest/testutils_test.go
@@ -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
}
diff --git a/pkg/server/web/helpers.go b/pkg/server/web/helpers.go
index 838950f..50dcc61 100644
--- a/pkg/server/web/helpers.go
+++ b/pkg/server/web/helpers.go
@@ -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", "
\n", "\r", "
\n", "\n", "
\n")
- return template.HTML(replacer.Replace(text))
+ return replacer.Replace(text)
}
// WrapURL wraps a tag around the provided URL
diff --git a/pkg/server/web/helpers_test.go b/pkg/server/web/helpers_test.go
index 39dc6eb..9cc2968 100644
--- a/pkg/server/web/helpers_test.go
+++ b/pkg/server/web/helpers_test.go
@@ -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(""), template.HTML("<html>"))
-
- // Check for linebreaks
- assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line
\nbreak"))
- assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line
\nbreak"))
- assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line
\nbreak"))
-}
-
-func TestURLDetection(t *testing.T) {
- assert.Equal(t,
- TextToHTML("http://google.com/"),
- template.HTML("http://google.com/"))
- assert.Equal(t,
- TextToHTML("http://a.com/?q=a&n=v"),
- template.HTML("http://a.com/?q=a&n=v"))
+ testCases := []struct {
+ input, want string
+ }{
+ {
+ input: "html",
+ want: "html",
+ },
+ // Check it escapes.
+ {
+ input: "",
+ want: "<html>",
+ },
+ // Check for linebreaks.
+ {
+ input: "line\nbreak",
+ want: "line
\nbreak",
+ },
+ {
+ input: "line\r\nbreak",
+ want: "line
\nbreak",
+ },
+ {
+ input: "line\rbreak",
+ want: "line
\nbreak",
+ },
+ // Check URL detection.
+ {
+ input: "http://google.com/",
+ want: "http://google.com/",
+ },
+ {
+ input: "http://a.com/?q=a&n=v",
+ want: "http://a.com/?q=a&n=v",
+ },
+ {
+ input: "(http://a.com/?q=a&n=v)",
+ want: "(http://a.com/?q=a&n=v)",
+ },
+ }
+ 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)
+ }
+ })
+ }
}
diff --git a/pkg/server/web/server.go b/pkg/server/web/server.go
index bc7bc5e..34aa996 100644
--- a/pkg/server/web/server.go
+++ b/pkg/server/web/server.go
@@ -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 == "" {
diff --git a/pkg/webui/mailbox_controller.go b/pkg/webui/mailbox_controller.go
index 71dbd42..37013aa 100644
--- a/pkg/webui/mailbox_controller.go
+++ b/pkg/webui/mailbox_controller.go
@@ -6,139 +6,88 @@ 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,
+ })
}
// 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
}
-// 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
diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go
index d6da620..537abca 100644
--- a/pkg/webui/routes.go
+++ b/pkg/webui/routes.go
@@ -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")
}
diff --git a/ui/elm-package.json b/ui/elm-package.json
index 7901d01..95bd2ee 100644
--- a/ui/elm-package.json
+++ b/ui/elm-package.json
@@ -14,6 +14,9 @@
},
"/debug": {
"target": "http://localhost:9000"
+ },
+ "/serve": {
+ "target": "http://localhost:9000"
}
},
"dependencies": {
diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico
deleted file mode 100644
index d7057bd..0000000
Binary files a/ui/public/favicon.ico and /dev/null differ
diff --git a/ui/static/favicon.png b/ui/public/favicon.png
similarity index 100%
rename from ui/static/favicon.png
rename to ui/public/favicon.png
diff --git a/ui/public/index.html b/ui/public/index.html
index 40cb71b..feb4250 100644
--- a/ui/public/index.html
+++ b/ui/public/index.html
@@ -10,8 +10,8 @@
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
-
-