1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-06-10 07:13:38 +00:00

Merge in branch for msg monitor feature, closes #44

This commit is contained in:
James Hillyerd
2017-01-21 21:19:44 -08:00
33 changed files with 979 additions and 126 deletions
+17
View File
@@ -40,6 +40,8 @@ type WebConfig struct {
PublicDir string PublicDir string
GreetingFile string GreetingFile string
CookieAuthKey string CookieAuthKey string
MonitorVisible bool
MonitorHistory int
} }
// DataStoreConfig contains the mail store configuration // DataStoreConfig contains the mail store configuration
@@ -130,6 +132,8 @@ func LoadConfig(filename string) error {
requireOption(messages, "web", "template.dir") requireOption(messages, "web", "template.dir")
requireOption(messages, "web", "template.cache") requireOption(messages, "web", "template.cache")
requireOption(messages, "web", "public.dir") requireOption(messages, "web", "public.dir")
requireOption(messages, "web", "monitor.visible")
requireOption(messages, "web", "monitor.history")
requireOption(messages, "datastore", "path") requireOption(messages, "datastore", "path")
requireOption(messages, "datastore", "retention.minutes") requireOption(messages, "datastore", "retention.minutes")
requireOption(messages, "datastore", "retention.sleep.millis") requireOption(messages, "datastore", "retention.sleep.millis")
@@ -349,6 +353,19 @@ func parseWebConfig() error {
} }
webConfig.GreetingFile = str webConfig.GreetingFile = str
option = "monitor.visible"
flag, err = Config.Bool(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.MonitorVisible = flag
option = "monitor.history"
webConfig.MonitorHistory, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "cookie.auth.key" option = "cookie.auth.key"
if Config.HasOption(section, option) { if Config.HasOption(section, option) {
str, err = Config.String(section, option) str, err = Config.String(section, option)
+12
View File
@@ -91,6 +91,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+12
View File
@@ -93,6 +93,18 @@ greeting.file=/con/configuration/greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key #cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+12
View File
@@ -93,6 +93,18 @@ greeting.file=%(themes.dir)s/greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+12
View File
@@ -91,6 +91,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key #cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+12
View File
@@ -91,6 +91,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key #cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+12
View File
@@ -91,6 +91,18 @@ greeting.file=%(install.dir)s\themes\greeting.html
# and previous sessions will be invalidated. # and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key #cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
############################################################################# #############################################################################
[datastore] [datastore]
+6
View File
@@ -6,6 +6,8 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/smtpd"
) )
@@ -14,6 +16,8 @@ type Context struct {
Vars map[string]string Vars map[string]string
Session *sessions.Session Session *sessions.Session
DataStore smtpd.DataStore DataStore smtpd.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool IsJSON bool
} }
@@ -56,6 +60,8 @@ func NewContext(req *http.Request) (*Context, error) {
Vars: vars, Vars: vars,
Session: sess, Session: sess,
DataStore: DataStore, DataStore: DataStore,
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"), IsJSON: headerMatch(req, "Accept", "application/json"),
} }
return ctx, err return ctx, err
+21 -16
View File
@@ -3,17 +3,18 @@ package httpd
import ( import (
"context" "context"
"expvar"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"time" "time"
"github.com/goods/httpbuf"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/smtpd"
) )
@@ -24,6 +25,9 @@ var (
// DataStore is where all the mailboxes and messages live // DataStore is where all the mailboxes and messages live
DataStore smtpd.DataStore DataStore smtpd.DataStore
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
// Router is shared between httpd, webui and rest packages. It sends // Router is shared between httpd, webui and rest packages. It sends
// incoming requests to the correct handler function // incoming requests to the correct handler function
Router = mux.NewRouter() Router = mux.NewRouter()
@@ -33,15 +37,29 @@ var (
listener net.Listener listener net.Listener
sessionStore sessions.Store sessionStore sessions.Store
globalShutdown chan bool globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
) )
func init() {
m := expvar.NewMap("http")
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method // Initialize sets up things for unit tests or the Start() method
func Initialize(cfg config.WebConfig, ds smtpd.DataStore, shutdownChan chan bool) { func Initialize(
cfg config.WebConfig,
shutdownChan chan bool,
ds smtpd.DataStore,
mh *msghub.Hub) {
webConfig = cfg webConfig = cfg
globalShutdown = shutdownChan globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers // NewContext() will use this DataStore for the web handlers
DataStore = ds DataStore = ds
msgHub = mh
// Content Paths // Content Paths
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir) log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
@@ -122,26 +140,13 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
defer ctx.Close() defer ctx.Close()
// Run the handler, grab the error, and report it // Run the handler, grab the error, and report it
buf := new(httpbuf.Buffer)
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI) log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
err = h(buf, req, ctx) err = h(w, req, ctx)
if err != nil { if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err) log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// Save the session
if err = ctx.Session.Save(req, buf); err != nil {
log.Errorf("HTTP failed to save session: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply the buffered response to the writer
if _, err = buf.Apply(w); err != nil {
log.Errorf("HTTP failed to write response: %v", err)
}
} }
func emergencyShutdown() { func emergencyShutdown() {
+6 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/pop3d" "github.com/jhillyerd/inbucket/pop3d"
"github.com/jhillyerd/inbucket/rest" "github.com/jhillyerd/inbucket/rest"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/smtpd"
@@ -95,11 +96,14 @@ func main() {
} }
} }
// Create message hub
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
// Grab our datastore // Grab our datastore
ds := smtpd.DefaultFileDataStore() ds := smtpd.DefaultFileDataStore()
// Start HTTP server // Start HTTP server
httpd.Initialize(config.GetWebConfig(), ds, shutdownChan) httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
webui.SetupRoutes(httpd.Router) webui.SetupRoutes(httpd.Router)
rest.SetupRoutes(httpd.Router) rest.SetupRoutes(httpd.Router)
go httpd.Start(rootCtx) go httpd.Start(rootCtx)
@@ -110,7 +114,7 @@ func main() {
go pop3Server.Start(rootCtx) go pop3Server.Start(rootCtx)
// Startup SMTP server // Startup SMTP server
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), ds, shutdownChan) smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
go smtpServer.Start(rootCtx) go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel // Loop forever waiting for signals or shutdown channel
+116
View File
@@ -0,0 +1,116 @@
package msghub
import (
"container/ring"
"context"
"sync"
"time"
)
// Message contains the basic header data for a message
type Message struct {
Mailbox string
ID string
From string
To []string
Subject string
Date time.Time
Size int64
}
// Listener receives the contents of the log, followed by new messages
type Listener interface {
Receive(msg Message) error
}
// Hub relays messages on to its listeners
type Hub struct {
// log stores history, points next spot to write. First non-nil entry is oldest Message
log *ring.Ring
logMx sync.RWMutex
// listeners interested in new messages
listeners map[Listener]struct{}
listenersMx sync.RWMutex
// broadcast receives new messages
broadcast chan Message
}
// New constructs a new Hub which will cache logSize messages in memory for playback to future
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
// context is canceled.
func New(ctx context.Context, logSize int) *Hub {
h := &Hub{
log: ring.New(logSize),
listeners: make(map[Listener]struct{}),
broadcast: make(chan Message, 100),
}
go func() {
for {
select {
case <-ctx.Done():
// Shutdown
close(h.broadcast)
h.broadcast = nil
return
case msg := <-h.broadcast:
// Log message
h.logMx.Lock()
h.log.Value = msg
h.log = h.log.Next()
h.logMx.Unlock()
// Deliver message to listeners
h.deliver(msg)
}
}
}()
return h
}
// Broadcast queues a message for processing by the hub. The message will be placed into the
// in-memory log and relayed to all registered listeners.
func (h *Hub) Broadcast(msg Message) {
if h.broadcast != nil {
h.broadcast <- msg
}
}
// AddListener registers a listener to receive broadcasted messages.
func (h *Hub) AddListener(l Listener) {
// Playback log
h.logMx.RLock()
h.log.Do(func(v interface{}) {
if v != nil {
l.Receive(v.(Message))
}
})
h.logMx.RUnlock()
// Add to listeners
h.listenersMx.Lock()
h.listeners[l] = struct{}{}
h.listenersMx.Unlock()
}
// RemoveListener deletes a listener registration, it will cease to receive messages.
func (h *Hub) RemoveListener(l Listener) {
h.listenersMx.Lock()
defer h.listenersMx.Unlock()
if _, ok := h.listeners[l]; ok {
delete(h.listeners, l)
}
}
// deliver message to all listeners, removing listeners if they return an error
func (h *Hub) deliver(msg Message) {
h.listenersMx.RLock()
defer h.listenersMx.RUnlock()
for l := range h.listeners {
if err := l.Receive(msg); err != nil {
h.RemoveListener(l)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
package msghub
import (
"context"
"fmt"
"testing"
"time"
)
// testListener implements the Listener interface, mock for unit tests
type testListener struct {
messages []*Message // received messages
wantMessages int // how many messages this listener wants to receive
errorAfter int // when != 0, messages until Receive() begins returning error
done chan struct{} // closed once we have received wantMessages
overflow chan struct{} // closed if we receive wantMessages+1
}
func newTestListener(want int) *testListener {
l := &testListener{
messages: make([]*Message, 0, want*2),
wantMessages: want,
done: make(chan struct{}),
overflow: make(chan struct{}),
}
if want == 0 {
close(l.done)
}
return l
}
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
// if instructed
func (l *testListener) Receive(msg Message) error {
l.messages = append(l.messages, &msg)
if len(l.messages) == l.wantMessages {
close(l.done)
}
if len(l.messages) == l.wantMessages+1 {
close(l.overflow)
}
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
return fmt.Errorf("Too many messages")
}
return nil
}
// String formats the got vs wanted message counts
func (l *testListener) String() string {
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
}
func TestHubNew(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
if hub == nil {
t.Fatal("New() == nil, expected a new Hub")
}
}
func TestHubZeroListeners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
for i := 0; i < 100; i++ {
hub.Broadcast(m)
}
// Just making sure Hub doesn't panic
}
func TestHubOneListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Broadcast(m)
// Wait for messages
select {
case <-l.done:
case <-time.After(time.Second):
t.Error("Timeout:", l)
}
}
func TestHubRemoveListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Broadcast(m)
hub.RemoveListener(l)
hub.Broadcast(m)
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(250 * time.Millisecond):
// Expected result, no overflow
}
}
func TestHubRemoveListenerOnError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
// error after 1 means listener should receive 2 messages before being removed
l := newTestListener(2)
l.errorAfter = 1
hub.AddListener(l)
hub.Broadcast(m)
hub.Broadcast(m)
hub.Broadcast(m)
hub.Broadcast(m)
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(250 * time.Millisecond):
// Expected result, no overflow
}
}
func TestHubLogReplay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 100)
l1 := newTestListener(3)
hub.AddListener(l1)
// Broadcast 3 messages with no listeners
msgs := make([]Message, 3)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Broadcast(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
// Add a new listener
l2 := newTestListener(3)
hub.AddListener(l2)
// Wait for messages (log)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
for i := 0; i < len(msgs); i++ {
got := l2.messages[i].Subject
want := msgs[i].Subject
if got != want {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
}
}
}
func TestHubLogReplayWrap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
l1 := newTestListener(20)
hub.AddListener(l1)
// Broadcast more messages than the hub can hold
msgs := make([]Message, 20)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Broadcast(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
// Add a new listener
l2 := newTestListener(5)
hub.AddListener(l2)
// Wait for messages (log)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
for i := 0; i < 5; i++ {
got := l2.messages[i].Subject
want := msgs[i+15].Subject
if got != want {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
}
}
}
func TestHubContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Broadcast(m)
cancel()
time.Sleep(50 * time.Millisecond)
hub.Broadcast(m)
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(250 * time.Millisecond):
// Expected result, no overflow
}
}
+12 -5
View File
@@ -6,9 +6,16 @@ import "github.com/jhillyerd/inbucket/httpd"
// SetupRoutes populates the routes for the REST interface // SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) { func SetupRoutes(r *mux.Router) {
// API v1 // API v1
r.Path("/api/v1/mailbox/{name}").Handler(httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET") r.Path("/api/v1/mailbox/{name}").Handler(
r.Path("/api/v1/mailbox/{name}").Handler(httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE") httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET") r.Path("/api/v1/mailbox/{name}").Handler(
r.Path("/api/v1/mailbox/{name}/{id}").Handler(httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE") httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET") r.Path("/api/v1/mailbox/{name}/{id}").Handler(
httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/monitor/all/messages").Handler(
httpd.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
} }
+160
View File
@@ -0,0 +1,160 @@
package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/rest/model"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 512
)
// options for gorilla connection upgrader
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListener handles messages from the msghub
type msgListener struct {
hub *msghub.Hub
c chan msghub.Message
}
// newMsgListener creates a listener and registers it
func newMsgListener(hub *msghub.Hub) *msgListener {
ml := &msgListener{
hub: hub,
c: make(chan msghub.Message, 100),
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message
func (ml *msgListener) Receive(msg msghub.Message) error {
ml.c <- msg
return nil
}
// WSReader makes sure the websocket client is still connected
func (ml *msgListener) WSReader(conn *websocket.Conn) {
defer ml.Close()
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
log.Tracef("HTTP[%v] Got WebSocket pong", conn.RemoteAddr())
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err)
} else {
log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr())
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case msg, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
conn.SetWriteDeadline(time.Now().Add(writeWait))
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
log.Tracef("HTTP[%v] Sent WebSocket ping", conn.RemoteAddr())
}
}
}
// Close removes the listener registration
func (ml *msgListener) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
}
func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Upgrade to Websocket
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
httpd.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
httpd.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
ml := newMsgListener(ctx.MsgHub)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
+5
View File
@@ -50,6 +50,11 @@ func (m *MockMailbox) NewMessage() (smtpd.Message, error) {
return args.Get(0).(smtpd.Message), args.Error(1) return args.Get(0).(smtpd.Message), args.Error(1)
} }
func (m *MockMailbox) Name() string {
args := m.Called()
return args.String(0)
}
func (m *MockMailbox) String() string { func (m *MockMailbox) String() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/smtpd"
) )
@@ -199,7 +200,7 @@ func setupWebServer(ds smtpd.DataStore) *bytes.Buffer {
PublicDir: "../themes/bootstrap/public", PublicDir: "../themes/bootstrap/public",
} }
shutdownChan := make(chan bool) shutdownChan := make(chan bool)
httpd.Initialize(cfg, ds, shutdownChan) httpd.Initialize(cfg, shutdownChan, ds, &msghub.Hub{})
SetupRoutes(httpd.Router) SetupRoutes(httpd.Router)
return buf return buf
+1
View File
@@ -29,6 +29,7 @@ type Mailbox interface {
GetMessage(id string) (Message, error) GetMessage(id string) (Message, error)
Purge() error Purge() error
NewMessage() (Message, error) NewMessage() (Message, error)
Name() string
String() string String() string
} }
+4
View File
@@ -148,6 +148,10 @@ type FileMailbox struct {
messages []*FileMessage messages []*FileMessage
} }
func (mb *FileMailbox) Name() string {
return mb.name
}
func (mb *FileMailbox) String() string { func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]" return mb.name + "[" + mb.dirName + "]"
} }
+62 -40
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
) )
// State tracks the current mode of our SMTP state machine // State tracks the current mode of our SMTP state machine
@@ -67,6 +68,12 @@ var commands = map[string]bool{
"TURN": true, "TURN": true,
} }
// recipientDetails for message delivery
type recipientDetails struct {
address, localPart, domainPart string
mailbox Mailbox
}
// Session holds the state of an SMTP session // Session holds the state of an SMTP session
type Session struct { type Session struct {
server *Server server *Server
@@ -341,13 +348,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
// DATA // DATA
func (ss *Session) dataHandler() { func (ss *Session) dataHandler() {
type RecipientDetails struct { recipients := make([]recipientDetails, 0, ss.recipients.Len())
address, localPart, domainPart string
mailbox Mailbox
}
recipients := make([]RecipientDetails, 0, ss.recipients.Len())
// Timestamp for Received header
stamp := time.Now().Format(timeStampFormat)
// Get a Mailbox and a new Message for each recipient // Get a Mailbox and a new Message for each recipient
msgSize := 0 msgSize := 0
if ss.server.storeMessages { if ss.server.storeMessages {
@@ -369,7 +370,7 @@ func (ss *Session) dataHandler() {
ss.reset() ss.reset()
return return
} }
recipients = append(recipients, RecipientDetails{recip, local, domain, mb}) recipients = append(recipients, recipientDetails{recip, local, domain, mb})
} else { } else {
log.Tracef("Not storing message for %q", recip) log.Tracef("Not storing message for %q", recip)
} }
@@ -399,39 +400,15 @@ func (ss *Session) dataHandler() {
if ss.server.storeMessages { if ss.server.storeMessages {
// Create a message for each valid recipient // Create a message for each valid recipient
for _, r := range recipients { for _, r := range recipients {
msg, err := r.mailbox.NewMessage() if ok := ss.deliverMessage(r, msgBuf); ok {
if err != nil {
ss.logError("Failed to create message for %q: %s", r.localPart, err)
ss.send(fmt.Sprintf("451 Failed to create message for %v", r.localPart))
ss.reset()
return
}
// Generate Received header
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp)
if err := msg.Append([]byte(recd)); err != nil {
ss.logError("Failed to write received header for %q: %s", r.localPart, err)
ss.send(fmt.Sprintf("451 Failed to create message for %v", r.localPart))
ss.reset()
return
}
// Append lines from msgBuf
for _, line = range msgBuf {
if err := msg.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v",
r.mailbox, err)
ss.send("554 Something went wrong")
ss.reset()
// Should really cleanup the crap on filesystem
return
}
}
if err := msg.Close(); err != nil {
ss.logError("Error: %v while writing message", err)
}
expReceivedTotal.Add(1) expReceivedTotal.Add(1)
} // end for } else {
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
}
} else { } else {
expReceivedTotal.Add(1) expReceivedTotal.Add(1)
} }
@@ -458,6 +435,51 @@ func (ss *Session) dataHandler() {
} // end for } // end for
} }
// deliverMessage creates and populates a new Message for the specified recipient
func (ss *Session) deliverMessage(r recipientDetails, msgBuf [][]byte) (ok bool) {
msg, err := r.mailbox.NewMessage()
if err != nil {
ss.logError("Failed to create message for %q: %s", r.localPart, err)
return false
}
// Generate Received header
stamp := time.Now().Format(timeStampFormat)
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp)
if err := msg.Append([]byte(recd)); err != nil {
ss.logError("Failed to write received header for %q: %s", r.localPart, err)
return false
}
// Append lines from msgBuf
for _, line := range msgBuf {
if err := msg.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v", r.mailbox, err)
// Should really cleanup the crap on filesystem
return false
}
}
if err := msg.Close(); err != nil {
ss.logError("Error while closing message for %v: %v", r.mailbox, err)
return false
}
// Broadcast message information
broadcast := msghub.Message{
Mailbox: r.mailbox.Name(),
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
}
ss.server.msgHub.Broadcast(broadcast)
return true
}
func (ss *Session) enterState(state State) { func (ss *Session) enterState(state State) {
ss.state = state ss.state = state
ss.logTrace("Entering state %v", state) ss.logTrace("Entering state %v", state)
+18 -2
View File
@@ -5,13 +5,15 @@ import (
"fmt" "fmt"
"io" "io"
"github.com/jhillyerd/inbucket/config"
"log" "log"
"net" "net"
"net/textproto" "net/textproto"
"os" "os"
"testing" "testing"
"time" "time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/msghub"
) )
type scriptStep struct { type scriptStep struct {
@@ -153,6 +155,13 @@ func TestMailState(t *testing.T) {
msg1 := &MockMessage{} msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil) mds.On("MailboxFor").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil) mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil) msg1.On("Close").Return(nil)
server, logbuf := setupSMTPServer(mds) server, logbuf := setupSMTPServer(mds)
@@ -263,6 +272,13 @@ func TestDataState(t *testing.T) {
msg1 := &MockMessage{} msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil) mds.On("MailboxFor").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil) mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil) msg1.On("Close").Return(nil)
server, logbuf := setupSMTPServer(mds) server, logbuf := setupSMTPServer(mds)
@@ -378,7 +394,7 @@ func setupSMTPServer(ds DataStore) (*Server, *bytes.Buffer) {
// Create a server, don't start it // Create a server, don't start it
shutdownChan := make(chan bool) shutdownChan := make(chan bool)
return NewServer(cfg, ds, shutdownChan), buf return NewServer(cfg, shutdownChan, ds, &msghub.Hub{}), buf
} }
var sessionNum int var sessionNum int
+18 -10
View File
@@ -12,24 +12,27 @@ import (
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
) )
// Server holds the configuration and state of our SMTP server // Server holds the configuration and state of our SMTP server
type Server struct { type Server struct {
// Configuration
domain string domain string
domainNoStore string domainNoStore string
maxRecips int maxRecips int
maxIdleSeconds int maxIdleSeconds int
maxMessageBytes int maxMessageBytes int
dataStore DataStore
storeMessages bool storeMessages bool
listener net.Listener
// globalShutdown is the signal Inbucket needs to shut down // Dependencies
globalShutdown chan bool dataStore DataStore // Mailbox/message store
globalShutdown chan bool // Shuts down Inbucket
msgHub *msghub.Hub // Pub/sub for message info
// waitgroup tracks individual sessions // State
waitgroup *sync.WaitGroup listener net.Listener // Incoming network connections
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
} }
var ( var (
@@ -54,17 +57,22 @@ var (
) )
// NewServer creates a new Server instance with the specificed config // NewServer creates a new Server instance with the specificed config
func NewServer(cfg config.SMTPConfig, ds DataStore, globalShutdown chan bool) *Server { func NewServer(
cfg config.SMTPConfig,
globalShutdown chan bool,
ds DataStore,
msgHub *msghub.Hub) *Server {
return &Server{ return &Server{
dataStore: ds,
domain: cfg.Domain, domain: cfg.Domain,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
maxRecips: cfg.MaxRecipients, maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds, maxIdleSeconds: cfg.MaxIdleSeconds,
maxMessageBytes: cfg.MaxMessageBytes, maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages, storeMessages: cfg.StoreMessages,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
waitgroup: new(sync.WaitGroup),
globalShutdown: globalShutdown, globalShutdown: globalShutdown,
dataStore: ds,
msgHub: msgHub,
waitgroup: new(sync.WaitGroup),
} }
} }
+5
View File
@@ -106,6 +106,11 @@ func (m *MockMailbox) NewMessage() (Message, error) {
return args.Get(0).(Message), args.Error(1) return args.Get(0).(Message), args.Error(1)
} }
func (m *MockMailbox) Name() string {
args := m.Called()
return args.String(0)
}
func (m *MockMailbox) String() string { func (m *MockMailbox) String() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
+1 -1
View File
@@ -31,7 +31,7 @@ export SWAKS_OPT_to="$to@inbucket.local"
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
# Multi-recipient test # Multi-recipient test
swaks $* --to="$to@inbucket.local,Alt User <alternate@inbucket.local>" --h-Subject: "Swaks Multi-Recipient" \ swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
--body text.txt --body text.txt
# HTML test # HTML test
+9
View File
@@ -81,3 +81,12 @@ table.metrics {
width: 200px; width: 200px;
} }
/* Monitor */
#monitor-message-list td {
cursor: pointer;
font-size: 12px;
}
#conn-status {
font-style: italic;
}
+1
View File
@@ -104,6 +104,7 @@ function displayMetrics(data, textStatus, jqXHR) {
metric('memstatsHeapSys', data.memstats.HeapSys, sizeFilter, true); metric('memstatsHeapSys', data.memstats.HeapSys, sizeFilter, true);
metric('memstatsHeapObjects', data.memstats.HeapObjects, numberFilter, true); metric('memstatsHeapObjects', data.memstats.HeapObjects, numberFilter, true);
metric('smtpConnectsCurrent', data.smtp.ConnectsCurrent, numberFilter, true); metric('smtpConnectsCurrent', data.smtp.ConnectsCurrent, numberFilter, true);
metric('httpWebSocketConnectsCurrent', data.http.WebSocketConnectsCurrent, numberFilter, true);
// Server-side history // Server-side history
metric('smtpReceivedTotal', data.smtp.ReceivedTotal, numberFilter, false); metric('smtpReceivedTotal', data.smtp.ReceivedTotal, numberFilter, false);
+45
View File
@@ -0,0 +1,45 @@
var baseURL = window.location.protocol + '//' + window.location.host;
function startMonitor() {
$.addTemplateFormatter({
"date": function(value, template) {
return moment(value).calendar();
},
"subject": function(value, template) {
if (value == null || value.length == 0) {
return "(No Subject)";
}
return value;
}
});
var uri = '/api/v1/monitor/all/messages'
var l = window.location;
var url = ((l.protocol === "https:") ? "wss://" : "ws://") + l.host + uri
var ws = new WebSocket(url);
ws.addEventListener('open', function (e) {
$('#conn-status').text('Connected.');
});
ws.addEventListener('message', function (e) {
var msg = JSON.parse(e.data);
msg['href'] = '/mailbox?name=' + msg.mailbox + '&id=' + msg.id;
$('#monitor-message-list').loadTemplate(
$('#message-template'),
msg,
{ append: true });
});
ws.addEventListener('close', function (e) {
$('#conn-status').text('Disconnected!');
});
}
function messageClick(node) {
var href = node.attributes['href'].value;
var url = baseURL + href;
window.location.assign(url);
}
function clearClick() {
$('#monitor-message-list').empty();
}
+6 -3
View File
@@ -48,7 +48,10 @@
</ul> </ul>
</li> </li>
{{end}} {{end}}
<li id="nav-status"><a href="/status" accesskey="2">Status</a></li> {{if .ctx.WebConfig.MonitorVisible}}
<li id="nav-monitor"><a href="/monitor" accesskey="2">Monitor</a></li>
{{end}}
<li id="nav-status"><a href="/status" accesskey="3">Status</a></li>
</ul> </ul>
<form class="navbar-form navbar-right" action="{{reverse "MailboxIndex"}}" method="GET"> <form class="navbar-form navbar-right" action="{{reverse "MailboxIndex"}}" method="GET">
<div class="form-group"> <div class="form-group">
@@ -68,9 +71,9 @@
</nav> </nav>
<div class="container"> <div class="container">
{{with .ctx.Session.Flashes "errors"}} {{with .errorFlash}}
<div class="alert alert-danger"> <div class="alert alert-danger">
<p>Please fix the following errors and resubmit:<p> <p>Please fix the following errors and try again:<p>
<ul> <ul>
{{range .}} {{range .}}
<li>{{.}}</li> <li>{{.}}</li>
@@ -64,16 +64,6 @@ $(document).ready(function() {
</div> </div>
</div> </div>
<div id="message-container" class="col-md-9"> <div id="message-container" class="col-md-9">
{{with .ctx.Session.Flashes "errors"}}
<div class="errors">
<p>Please fix the following errors and resubmit:<p>
<ul>
{{range .}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
{{end}}
<div id="message-content"> <div id="message-content">
<p>Select a message at left, or enter a different username into the box on upper right.</p> <p>Select a message at left, or enter a different username into the box on upper right.</p>
</div> </div>
@@ -0,0 +1,53 @@
{{define "title"}}Inbucket Monitor{{end}}
{{define "script"}}
<script src="/public/monitor.js" type="text/javascript" charset="utf-8"></script>
<script>
$(document).ready(function () {
$('#nav-monitor').addClass('active');
startMonitor();
});
</script>
<script type="text/html" id="message-template">
<tr data-href="href" onclick="messageClick(this);">
<td data-content="date" data-format="date"/>
<td data-content-text="from"/>
<td data-content-text="mailbox"/>
<td data-content-text="subject" data-format="subject"/>
</tr>
</script>
{{end}}
{{define "menu"}}
<div id="logo">
<h1><a href="/">inbucket</a></h1>
<h2>email testing service</h2>
</div>
{{end}}
{{define "content"}}
<h2>Inbucket Monitor</h2>
<div class="pull-right">
<button class="btn btn-primary" onclick="clearClick();">Clear</button>
</div>
<p class="small">Messages will be listed here shortly after delivery.</p>
<div class="table-responsive clearfix">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Mailbox</th>
<th>Subject</th>
</tr>
</thead>
<tbody id="monitor-message-list"></tbody>
</table>
</div>
<div id="conn-status">Connecting...</div>
{{end}}
@@ -107,6 +107,12 @@ $(document).ready(
<div class="col-sm-4"><span id="s-memstatsHeapObjects">.</span></div> <div class="col-sm-4"><span id="s-memstatsHeapObjects">.</span></div>
<div class="col-sm-2 hidden-xs">(10min)</div> <div class="col-sm-2 hidden-xs">(10min)</div>
</div> </div>
<div class="row">
<div class="col-sm-3 col-xs-7"><b>Open WebSockets:</b></div>
<div class="col-sm-3 col-xs-5"><span id="m-httpWebSocketConnectsCurrent">.</span></div>
<div class="col-sm-4"><span id="s-httpWebSocketConnectsCurrent">.</span></div>
<div class="col-sm-2 hidden-xs">(10min)</div>
</div>
</table> </table>
</div> </div>
</div> </div>
+24 -15
View File
@@ -17,25 +17,30 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
// Form values must be validated manually // Form values must be validated manually
name := req.FormValue("name") name := req.FormValue("name")
selected := req.FormValue("id") selected := req.FormValue("id")
if len(name) == 0 { if len(name) == 0 {
ctx.Session.AddFlash("Account name is required", "errors") ctx.Session.AddFlash("Account name is required", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
name, err = smtpd.ParseMailboxName(name) name, err = smtpd.ParseMailboxName(name)
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
// Remember this mailbox was visited // Remember this mailbox was visited
RememberMailbox(ctx, name) 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 httpd.RenderTemplate("mailbox/index.html", w, map[string]interface{}{ return httpd.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
"errorFlash": errorFlash,
"name": name, "name": name,
"selected": selected, "selected": selected,
}) })
@@ -48,16 +53,17 @@ func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", httpd.Reverse("MailboxIndex"), name, id) uri := fmt.Sprintf("%s?name=%s&id=%s", httpd.Reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther) http.Redirect(w, req, uri, http.StatusSeeOther)
return nil return nil
} }
// MailboxList renders a list of messages in a mailbox. Renders JSON or a partial // MailboxList renders a list of messages in a mailbox. Renders a partial
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404 // Don't have to validate these aren't empty, Gorilla returns 404
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
@@ -75,7 +81,7 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
return fmt.Errorf("Failed to get messages for %v: %v", name, err) return fmt.Errorf("Failed to get messages for %v: %v", name, err)
} }
log.Tracef("Got %v messsages", len(messages)) log.Tracef("Got %v messsages", len(messages))
// Render partial template
return httpd.RenderPartial("mailbox/_list.html", w, map[string]interface{}{ return httpd.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
"name": name, "name": name,
@@ -109,10 +115,9 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
if err != nil { if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err) return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
} }
body := template.HTML(httpd.TextToHTML(mime.Text)) body := template.HTML(httpd.TextToHTML(mime.Text))
htmlAvailable := mime.HTML != "" htmlAvailable := mime.HTML != ""
// Render partial template
return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
"name": name, "name": name,
@@ -150,7 +155,7 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
if err != nil { if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err) return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
} }
// Render partial template
w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Content-Type", "text/html; charset=UTF-8")
return httpd.RenderPartial("mailbox/_html.html", w, map[string]interface{}{ return httpd.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
@@ -187,7 +192,7 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
if err != nil { if err != nil {
return fmt.Errorf("ReadRaw(%q) failed: %v", id, err) return fmt.Errorf("ReadRaw(%q) failed: %v", id, err)
} }
// Output message source
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
if _, err := io.WriteString(w, *raw); err != nil { if _, err := io.WriteString(w, *raw); err != nil {
return err return err
@@ -203,6 +208,7 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
@@ -210,10 +216,10 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
num, err := strconv.ParseUint(numStr, 10, 32) num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil { if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
mb, err := ctx.DataStore.MailboxFor(name) mb, err := ctx.DataStore.MailboxFor(name)
if err != nil { if err != nil {
// This doesn't indicate not found, likely an IO error // This doesn't indicate not found, likely an IO error
@@ -234,11 +240,12 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
} }
if int(num) >= len(body.Attachments) { if int(num) >= len(body.Attachments) {
ctx.Session.AddFlash("Attachment number too high", "errors") ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
part := body.Attachments[num] part := body.Attachments[num]
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment") w.Header().Set("Content-Disposition", "attachment")
if _, err := io.Copy(w, part); err != nil { if _, err := io.Copy(w, part); err != nil {
@@ -253,6 +260,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
@@ -261,10 +269,10 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont
num, err := strconv.ParseUint(numStr, 10, 32) num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil { if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors") ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
mb, err := ctx.DataStore.MailboxFor(name) mb, err := ctx.DataStore.MailboxFor(name)
if err != nil { if err != nil {
// This doesn't indicate not found, likely an IO error // This doesn't indicate not found, likely an IO error
@@ -285,11 +293,12 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont
} }
if int(num) >= len(body.Attachments) { if int(num) >= len(body.Attachments) {
ctx.Session.AddFlash("Attachment number too high", "errors") ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
part := body.Attachments[num] part := body.Attachments[num]
// Output attachment
w.Header().Set("Content-Type", part.ContentType) w.Header().Set("Content-Type", part.ContentType)
if _, err := io.Copy(w, part); err != nil { if _, err := io.Copy(w, part); err != nil {
return err return err
+34 -1
View File
@@ -16,13 +16,39 @@ func RootIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (er
if err != nil { if err != nil {
return fmt.Errorf("Failed to load greeting: %v", err) return fmt.Errorf("Failed to load greeting: %v", err)
} }
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return httpd.RenderTemplate("root/index.html", w, map[string]interface{}{ return httpd.RenderTemplate("root/index.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
"errorFlash": errorFlash,
"greeting": template.HTML(string(greeting)), "greeting": template.HTML(string(greeting)),
}) })
} }
// RootMonitor serves the Inbucket monitor page
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
if !config.GetWebConfig().MonitorVisible {
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return httpd.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
})
}
// RootStatus serves the Inbucket status page // RootStatus serves the Inbucket status page
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { func RootStatus(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
smtpListener := fmt.Sprintf("%s:%d", config.GetSMTPConfig().IP4address.String(), smtpListener := fmt.Sprintf("%s:%d", config.GetSMTPConfig().IP4address.String(),
@@ -31,8 +57,15 @@ func RootStatus(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (e
config.GetPOP3Config().IP4port) config.GetPOP3Config().IP4port)
webListener := fmt.Sprintf("%s:%d", config.GetWebConfig().IP4address.String(), webListener := fmt.Sprintf("%s:%d", config.GetWebConfig().IP4address.String(),
config.GetWebConfig().IP4port) config.GetWebConfig().IP4port)
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return httpd.RenderTemplate("root/status.html", w, map[string]interface{}{ return httpd.RenderTemplate("root/status.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
"errorFlash": errorFlash,
"version": config.Version, "version": config.Version,
"buildDate": config.BuildDate, "buildDate": config.BuildDate,
"smtpListener": smtpListener, "smtpListener": smtpListener,
+22 -10
View File
@@ -8,14 +8,26 @@ import (
// 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) { func SetupRoutes(r *mux.Router) {
r.Path("/").Handler(httpd.Handler(RootIndex)).Name("RootIndex").Methods("GET") r.Path("/").Handler(
r.Path("/status").Handler(httpd.Handler(RootStatus)).Name("RootStatus").Methods("GET") httpd.Handler(RootIndex)).Name("RootIndex").Methods("GET")
r.Path("/link/{name}/{id}").Handler(httpd.Handler(MailboxLink)).Name("MailboxLink").Methods("GET") r.Path("/monitor").Handler(
r.Path("/mailbox").Handler(httpd.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET") httpd.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
r.Path("/mailbox/{name}").Handler(httpd.Handler(MailboxList)).Name("MailboxList").Methods("GET") r.Path("/status").Handler(
r.Path("/mailbox/{name}/{id}").Handler(httpd.Handler(MailboxShow)).Name("MailboxShow").Methods("GET") httpd.Handler(RootStatus)).Name("RootStatus").Methods("GET")
r.Path("/mailbox/{name}/{id}/html").Handler(httpd.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET") r.Path("/link/{name}/{id}").Handler(
r.Path("/mailbox/{name}/{id}/source").Handler(httpd.Handler(MailboxSource)).Name("MailboxSource").Methods("GET") httpd.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(httpd.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET") r.Path("/mailbox").Handler(
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(httpd.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET") httpd.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
r.Path("/mailbox/{name}").Handler(
httpd.Handler(MailboxList)).Name("MailboxList").Methods("GET")
r.Path("/mailbox/{name}/{id}").Handler(
httpd.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
r.Path("/mailbox/{name}/{id}/html").Handler(
httpd.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
r.Path("/mailbox/{name}/{id}/source").Handler(
httpd.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
httpd.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
httpd.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
} }