diff --git a/README.md b/README.md index be0f5eb..6c300d6 100644 --- a/README.md +++ b/README.md @@ -12,32 +12,37 @@ no password is required to browse the content of the mailboxes. Inbucket has a built-in SMTP server and stores incoming mail as flat files on disk - no external SMTP or database daemons required. +Features +-------- + * Receive and store E/SMTP messages + * List messages in a mailbox + * Displays: + - Text content of a particular message + - Source of a message (headers + body text) + - HTML version of a message (in a new window) + * List MIME attachments with buttons to display or download + * Delete a message + * Purge messages after a configurable amount of time + * Optional load test mode; messages are never written to disk + +It does not yet: + + * Display inline attachments within HTML email + Screenshots ----------- ![An Email](http://cloud.github.com/downloads/jhillyerd/inbucket/inbucket-ss1.png) *Viewing an email in Inbucket.* ![Metrics](http://cloud.github.com/downloads/jhillyerd/inbucket/inbucket-ss2.png) -*Watching metrics while Inbucket handles over 4,000 messages per minute.* +*Watching metrics while Inbucket recieves and stores over 4,000 messages per minute.* Development Status ------------------ -Inbucket is currently alpha quality: it works but is not well tested. +Inbucket is currently beta quality: it works but is not well tested. -It can: - - * Receive SMTP and ESMTP messages and store them to disk - * List subject, sender and date of messages for a particular mailbox - * Parse MIME multipart emails - * Display the content of a particular message - * Display the source of a message (headers + body text) - * Display the HTML version of a message (in a new window) - * Delete a message - * Purge messages after a configurable amount of time - -It does not yet: - - * Display or download attachments +Please check the [issues list](https://github.com/jhillyerd/inbucket/issues?state=open) +for more details. Installation ------------ @@ -55,6 +60,8 @@ Unix and OS X machines as is. Launch the daemon: By default the SMTP server will be listening on localhost port 2500 and the web interface will be available at [localhost:9000](http://localhost:9000/). +There are RedHat EL6 init, logrotate and httpd proxy configs provided. + About ----- Inbucket is written in [Google Go][1]. diff --git a/bin/dist-unix.sh b/bin/dist-unix.sh index a9fb9c8..e62e389 100755 --- a/bin/dist-unix.sh +++ b/bin/dist-unix.sh @@ -10,6 +10,13 @@ label="$1" # Bail on error set -e +# For OS X +if [ -x /usr/bin/gnutar ]; then + tar=/usr/bin/gnutar +else + tar=tar +fi + # Work directory tmpdir=/tmp/inbucket-dist.$$ mkdir -p $tmpdir @@ -32,7 +39,7 @@ cp -r themes $distdir/themes echo "Tarballing..." tarball="$HOME/$distname.tbz2" cd $tmpdir -tar --owner=root --group=root -cjvf $tarball $distname +$tar --owner=root --group=0 -cjvf $tarball $distname echo "Cleaning up..." if [ "$tmpdir" != "/" ]; then diff --git a/etc/devel.conf b/etc/devel.conf index 11d42e8..ecb9b14 100644 --- a/etc/devel.conf +++ b/etc/devel.conf @@ -34,7 +34,7 @@ max.recipients=100 max.idle.seconds=30 # Maximum allowable size of message body in bytes (including attachments) -max.message.bytes=2048000 +max.message.bytes=20480000 # Should we place messages into the datastore, or just throw them away # (for load testing): true or false diff --git a/swaks-tests/run-tests.sh b/swaks-tests/run-tests.sh index be1246e..743bdae 100755 --- a/swaks-tests/run-tests.sh +++ b/swaks-tests/run-tests.sh @@ -10,4 +10,4 @@ swaks --h-Subject: "Swaks Plain Text" --body text.txt swaks --h-Subject: "Swaks HTML" --data html.raw # Attachment test -swaks --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png +swaks --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png --body text.txt diff --git a/themes/integral/public/main.css b/themes/integral/public/main.css index 0d2d3ae..4dd6f14 100644 --- a/themes/integral/public/main.css +++ b/themes/integral/public/main.css @@ -327,3 +327,28 @@ table.metrics { .metrics td.sparkline { width: 170px; } + +#emailAttachments { + border-collapse: collapse; +} + +#emailAttachments th, #emailAttachments td { + text-align: left; + padding: 0 3px 3px 0; +} + +#emailAttachments .fileName:before { + content: '\203A\00A0'; +} + +#emailAttachments a { + background: #8ac6dc; + color: #fff; + text-decoration: none; + padding: 0 5px; +} + +#emailAttachments a:hover { + background: #becf74; +} + diff --git a/themes/integral/templates/mailbox/_show.html b/themes/integral/templates/mailbox/_show.html index 116debd..bf83fcb 100644 --- a/themes/integral/templates/mailbox/_show.html +++ b/themes/integral/templates/mailbox/_show.html @@ -1,3 +1,5 @@ +{{$name := .name}} +{{$id := .message.Id}}
Delete Source @@ -15,6 +17,21 @@ {{.message.Date}} + +{{with .attachments}} +
+ + {{range $i, $e := .}} + + + + + + + {{end}} +
Attachments:
{{$e.FileName}}({{$e.ContentType}})ViewDownload
+{{end}} +

{{.message.Subject}}

{{.body}}
diff --git a/web/mailbox_controller.go b/web/mailbox_controller.go index 33176fa..3eec6dd 100644 --- a/web/mailbox_controller.go +++ b/web/mailbox_controller.go @@ -6,6 +6,7 @@ import ( "html/template" "io" "net/http" + "strconv" ) func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { @@ -23,12 +24,8 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *Context) (err e } func MailboxList(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 name := ctx.Vars["name"] - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } mb, err := ctx.DataStore.MailboxFor(name) if err != nil { @@ -48,18 +45,9 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *Context) (err er } func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 name := ctx.Vars["name"] id := ctx.Vars["id"] - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } - if len(id) == 0 { - ctx.Session.AddFlash("Message ID is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } mb, err := ctx.DataStore.MailboxFor(name) if err != nil { @@ -82,22 +70,14 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *Context) (err er "message": message, "body": body, "htmlAvailable": htmlAvailable, + "attachments": mime.Attachments, }) } func MailboxHtml(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 name := ctx.Vars["name"] id := ctx.Vars["id"] - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } - if len(id) == 0 { - ctx.Session.AddFlash("Message ID is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } mb, err := ctx.DataStore.MailboxFor(name) if err != nil { @@ -122,18 +102,9 @@ func MailboxHtml(w http.ResponseWriter, req *http.Request, ctx *Context) (err er } func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 name := ctx.Vars["name"] id := ctx.Vars["id"] - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } - if len(id) == 0 { - ctx.Session.AddFlash("Message ID is required", "errors") - http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) - return nil - } mb, err := ctx.DataStore.MailboxFor(name) if err != nil { @@ -153,19 +124,83 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *Context) (err return nil } -func MailboxDelete(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { +func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 name := ctx.Vars["name"] id := ctx.Vars["id"] - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") + numStr := ctx.Vars["num"] + num, err := strconv.ParseUint(numStr, 10, 32) + if err != nil { + ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) return nil } - if len(id) == 0 { - ctx.Session.AddFlash("Message ID is required", "errors") + + mb, err := ctx.DataStore.MailboxFor(name) + if err != nil { + return err + } + message, err := mb.GetMessage(id) + if err != nil { + return err + } + _, body, err := message.ReadBody() + if err != nil { + return err + } + if int(num) >= len(body.Attachments) { + ctx.Session.AddFlash("Attachment number too high", "errors") http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) return nil } + part := body.Attachments[num] + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment") + w.Write(part.Content()) + return nil +} + +func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 + name := ctx.Vars["name"] + id := ctx.Vars["id"] + numStr := ctx.Vars["num"] + num, err := strconv.ParseUint(numStr, 10, 32) + if err != nil { + ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") + http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) + return nil + } + + mb, err := ctx.DataStore.MailboxFor(name) + if err != nil { + return err + } + message, err := mb.GetMessage(id) + if err != nil { + return err + } + _, body, err := message.ReadBody() + if err != nil { + return err + } + if int(num) >= len(body.Attachments) { + ctx.Session.AddFlash("Attachment number too high", "errors") + http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther) + return nil + } + part := body.Attachments[num] + + w.Header().Set("Content-Type", part.ContentType()) + w.Write(part.Content()) + return nil +} + +func MailboxDelete(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 + name := ctx.Vars["name"] + id := ctx.Vars["id"] mb, err := ctx.DataStore.MailboxFor(name) if err != nil { diff --git a/web/server.go b/web/server.go index 534b572..531e741 100644 --- a/web/server.go +++ b/web/server.go @@ -38,6 +38,8 @@ func setupRoutes(cfg config.WebConfig) { r.Path("/mailbox/html/{name}/{id}").Handler(handler(MailboxHtml)).Name("MailboxHtml").Methods("GET") r.Path("/mailbox/source/{name}/{id}").Handler(handler(MailboxSource)).Name("MailboxSource").Methods("GET") r.Path("/mailbox/delete/{name}/{id}").Handler(handler(MailboxDelete)).Name("MailboxDelete").Methods("POST") + r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET") + r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET") // Register w/ HTTP Router = r