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

*Viewing an email in Inbucket.*

-*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}}
+
+ | Attachments: |
+ {{range $i, $e := .}}
+
+ | {{$e.FileName}} |
+ ({{$e.ContentType}}) |
+ View |
+ Download |
+
+ {{end}}
+
+{{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