1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 13:42:30 +00:00

Reorganize packages, closes #79

- All packages go into either cmd or pkg directories
- Most packages renamed
- Server packages moved into pkg/server
- sanitize moved into webui, as that's the only place it's used
- filestore moved into pkg/storage/file
- Makefile updated, and PKG variable use fixed
This commit is contained in:
James Hillyerd
2018-03-09 19:32:45 -08:00
parent f00b9ddef0
commit f8c30a678a
55 changed files with 225 additions and 220 deletions
+270
View File
@@ -0,0 +1,270 @@
package config
import (
"fmt"
"net"
"os"
"sort"
"strings"
"github.com/robfig/config"
)
// SMTPConfig contains the SMTP server configuration - not using pointers so that we can pass around
// copies of the object safely.
type SMTPConfig struct {
IP4address net.IP
IP4port int
Domain string
DomainNoStore string
MaxRecipients int
MaxIdleSeconds int
MaxMessageBytes int
StoreMessages bool
}
// POP3Config contains the POP3 server configuration
type POP3Config struct {
IP4address net.IP
IP4port int
Domain string
MaxIdleSeconds int
}
// WebConfig contains the HTTP server configuration
type WebConfig struct {
IP4address net.IP
IP4port int
TemplateDir string
TemplateCache bool
PublicDir string
GreetingFile string
MailboxPrompt string
CookieAuthKey string
MonitorVisible bool
MonitorHistory int
}
// DataStoreConfig contains the mail store configuration
type DataStoreConfig struct {
Path string
RetentionMinutes int
RetentionSleep int
MailboxMsgCap int
}
const (
missingErrorFmt = "[%v] missing required option %q"
parseErrorFmt = "[%v] option %q error: %v"
)
var (
// Version of this build, set by main
Version = ""
// BuildDate for this build, set by main
BuildDate = ""
// Config is our global robfig/config object
Config *config.Config
logLevel string
// Parsed specific configs
smtpConfig = &SMTPConfig{}
pop3Config = &POP3Config{}
webConfig = &WebConfig{}
dataStoreConfig = &DataStoreConfig{}
)
// GetSMTPConfig returns a copy of the SmtpConfig object
func GetSMTPConfig() SMTPConfig {
return *smtpConfig
}
// GetPOP3Config returns a copy of the Pop3Config object
func GetPOP3Config() POP3Config {
return *pop3Config
}
// GetWebConfig returns a copy of the WebConfig object
func GetWebConfig() WebConfig {
return *webConfig
}
// GetDataStoreConfig returns a copy of the DataStoreConfig object
func GetDataStoreConfig() DataStoreConfig {
return *dataStoreConfig
}
// GetLogLevel returns the configured log level
func GetLogLevel() string {
return logLevel
}
// LoadConfig loads the specified configuration file into inbucket.Config and performs validations
// on it.
func LoadConfig(filename string) error {
var err error
Config, err = config.ReadDefault(filename)
if err != nil {
return err
}
// Validation error messages
messages := make([]string, 0)
// Validate sections
for _, s := range []string{"logging", "smtp", "pop3", "web", "datastore"} {
if !Config.HasSection(s) {
messages = append(messages,
fmt.Sprintf("Config section [%v] is required", s))
}
}
// Return immediately if config is missing entire sections
if len(messages) > 0 {
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
for _, m := range messages {
fmt.Fprintln(os.Stderr, " -", m)
}
return fmt.Errorf("Failed to validate configuration")
}
// Load string config options
stringOptions := []struct {
section string
name string
target *string
required bool
}{
{"logging", "level", &logLevel, true},
{"smtp", "domain", &smtpConfig.Domain, true},
{"smtp", "domain.nostore", &smtpConfig.DomainNoStore, false},
{"pop3", "domain", &pop3Config.Domain, true},
{"web", "template.dir", &webConfig.TemplateDir, true},
{"web", "public.dir", &webConfig.PublicDir, true},
{"web", "greeting.file", &webConfig.GreetingFile, true},
{"web", "mailbox.prompt", &webConfig.MailboxPrompt, false},
{"web", "cookie.auth.key", &webConfig.CookieAuthKey, false},
{"datastore", "path", &dataStoreConfig.Path, true},
}
for _, opt := range stringOptions {
str, err := Config.String(opt.section, opt.name)
if Config.HasOption(opt.section, opt.name) && err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
continue
}
if str == "" && opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
*opt.target = str
}
// Load boolean config options
boolOptions := []struct {
section string
name string
target *bool
required bool
}{
{"smtp", "store.messages", &smtpConfig.StoreMessages, true},
{"web", "template.cache", &webConfig.TemplateCache, true},
{"web", "monitor.visible", &webConfig.MonitorVisible, true},
}
for _, opt := range boolOptions {
if Config.HasOption(opt.section, opt.name) {
flag, err := Config.Bool(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
}
*opt.target = flag
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
// Load integer config options
intOptions := []struct {
section string
name string
target *int
required bool
}{
{"smtp", "ip4.port", &smtpConfig.IP4port, true},
{"smtp", "max.recipients", &smtpConfig.MaxRecipients, true},
{"smtp", "max.idle.seconds", &smtpConfig.MaxIdleSeconds, true},
{"smtp", "max.message.bytes", &smtpConfig.MaxMessageBytes, true},
{"pop3", "ip4.port", &pop3Config.IP4port, true},
{"pop3", "max.idle.seconds", &pop3Config.MaxIdleSeconds, true},
{"web", "ip4.port", &webConfig.IP4port, true},
{"web", "monitor.history", &webConfig.MonitorHistory, true},
{"datastore", "retention.minutes", &dataStoreConfig.RetentionMinutes, true},
{"datastore", "retention.sleep.millis", &dataStoreConfig.RetentionSleep, true},
{"datastore", "mailbox.message.cap", &dataStoreConfig.MailboxMsgCap, true},
}
for _, opt := range intOptions {
if Config.HasOption(opt.section, opt.name) {
num, err := Config.Int(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
}
*opt.target = num
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
// Load IP address config options
ipOptions := []struct {
section string
name string
target *net.IP
required bool
}{
{"smtp", "ip4.address", &smtpConfig.IP4address, true},
{"pop3", "ip4.address", &pop3Config.IP4address, true},
{"web", "ip4.address", &webConfig.IP4address, true},
}
for _, opt := range ipOptions {
if Config.HasOption(opt.section, opt.name) {
str, err := Config.String(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
continue
}
addr := net.ParseIP(str)
if addr == nil {
messages = append(messages,
fmt.Sprintf("Failed to parse IP [%v]%v: %q", opt.section, opt.name, str))
continue
}
addr = addr.To4()
if addr == nil {
messages = append(messages,
fmt.Sprintf("Failed to parse IP [%v]%v: %q not IPv4!",
opt.section, opt.name, str))
}
*opt.target = addr
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
// Validate log level
switch strings.ToUpper(logLevel) {
case "":
// Missing was already reported
case "TRACE", "INFO", "WARN", "ERROR":
default:
messages = append(messages,
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
}
// Print messages and return error if any validations failed
if len(messages) > 0 {
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
sort.Strings(messages)
for _, m := range messages {
fmt.Fprintln(os.Stderr, " -", m)
}
return fmt.Errorf("Failed to validate configuration")
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package log
import (
"fmt"
golog "log"
"os"
"strings"
)
// Level is used to indicate the severity of a log entry
type Level int
const (
// ERROR indicates a significant problem was encountered
ERROR Level = iota
// WARN indicates something that may be a problem
WARN
// INFO indicates a purely informational log entry
INFO
// TRACE entries are meant for development purposes only
TRACE
)
var (
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
MaxLevel = TRACE
// logfname is the name of the logfile
logfname string
// logf is the file we send log output to, will be nil for stderr or stdout
logf *os.File
)
// Initialize logging. If logfile is equal to "stderr" or "stdout", then
// we will log to that output stream. Otherwise the specificed file will
// opened for writing, and all log data will be placed in it.
func Initialize(logfile string) error {
if logfile != "stderr" {
// stderr is the go logging default
if logfile == "stdout" {
// set to stdout
golog.SetOutput(os.Stdout)
} else {
logfname = logfile
if err := openLogFile(); err != nil {
return err
}
// Platform specific
closeStdin()
}
}
return nil
}
// SetLogLevel sets MaxLevel based on the provided string
func SetLogLevel(level string) (ok bool) {
switch strings.ToUpper(level) {
case "ERROR":
MaxLevel = ERROR
case "WARN":
MaxLevel = WARN
case "INFO":
MaxLevel = INFO
case "TRACE":
MaxLevel = TRACE
default:
Errorf("Unknown log level requested: " + level)
return false
}
return true
}
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
func Errorf(msg string, args ...interface{}) {
msg = "[ERROR] " + msg
golog.Printf(msg, args...)
}
// Warnf logs a message to the 'standard' Logger if MaxLevel is >= WARN, accepts format strings
func Warnf(msg string, args ...interface{}) {
if MaxLevel >= WARN {
msg = "[WARN ] " + msg
golog.Printf(msg, args...)
}
}
// Infof logs a message to the 'standard' Logger if MaxLevel is >= INFO, accepts format strings
func Infof(msg string, args ...interface{}) {
if MaxLevel >= INFO {
msg = "[INFO ] " + msg
golog.Printf(msg, args...)
}
}
// Tracef logs a message to the 'standard' Logger if MaxLevel is >= TRACE, accepts format strings
func Tracef(msg string, args ...interface{}) {
if MaxLevel >= TRACE {
msg = "[TRACE] " + msg
golog.Printf(msg, args...)
}
}
// Rotate closes the current log file, then reopens it. This gives an external
// log rotation system the opportunity to move the existing log file out of the
// way and have Inbucket create a new one.
func Rotate() {
// Rotate logs if configured
if logf != nil {
closeLogFile()
// There is nothing we can do if the log open fails
_ = openLogFile()
} else {
Infof("Ignoring SIGHUP, logfile not configured")
}
}
// Close the log file if we have one open
func Close() {
if logf != nil {
closeLogFile()
}
}
// openLogFile creates or appends to the logfile passed on commandline
func openLogFile() error {
// use specified log file
var err error
logf, err = os.OpenFile(logfname, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
return fmt.Errorf("Failed to create %v: %v\n", logfname, err)
}
golog.SetOutput(logf)
Tracef("Opened new logfile")
// Platform specific
reassignStdout()
return nil
}
// closeLogFile closes the current logfile
func closeLogFile() {
Tracef("Closing logfile")
// We are never in a situation where we can do anything about failing to close
_ = logf.Close()
}
+62
View File
@@ -0,0 +1,62 @@
package log
import (
"container/list"
"expvar"
"strings"
"time"
)
// TickerFunc is the type of metrics function accepted by AddTickerFunc
type TickerFunc func()
var tickerFuncChan = make(chan TickerFunc)
func init() {
go metricsTicker()
}
// AddTickerFunc adds a new function callback to the list of metrics TickerFuncs that get
// called each minute.
func AddTickerFunc(f TickerFunc) {
tickerFuncChan <- f
}
// PushMetric adds the metric to the end of the list and returns a comma separated string of the
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
// tracks deltas between these values - there is nothing to compare the first value against.
func PushMetric(history *list.List, ev expvar.Var) string {
history.PushBack(ev.String())
if history.Len() > 61 {
history.Remove(history.Front())
}
return joinStringList(history)
}
// joinStringList joins a List containing strings by commas
func joinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
return ""
}
s := make([]string, 0, listOfStrings.Len())
for e := listOfStrings.Front(); e != nil; e = e.Next() {
s = append(s, e.Value.(string))
}
return strings.Join(s, ",")
}
func metricsTicker() {
funcs := make([]TickerFunc, 0)
ticker := time.NewTicker(time.Minute)
for {
select {
case <-ticker.C:
for _, f := range funcs {
f()
}
case f := <-tickerFuncChan:
funcs = append(funcs, f)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
// +build !windows
package log
import (
"golang.org/x/sys/unix"
"os"
)
// closeStdin will close stdin on Unix platforms - this is standard practice
// for daemons
func closeStdin() {
if err := os.Stdin.Close(); err != nil {
// Not a fatal error
Errorf("Failed to close os.Stdin during log setup")
}
}
// reassignStdout points stdout/stderr to our logfile on systems that support
// the Dup2 syscall per https://github.com/golang/go/issues/325
func reassignStdout() {
Tracef("Unix reassignStdout()")
if err := unix.Dup2(int(logf.Fd()), 1); err != nil {
// Not considered fatal
Errorf("Failed to re-assign stdout to logfile: %v", err)
}
if err := unix.Dup2(int(logf.Fd()), 2); err != nil {
// Not considered fatal
Errorf("Failed to re-assign stderr to logfile: %v", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
// +build windows
package log
import (
"os"
)
var stdOutsClosed = false
// closeStdin does nothing on Windows, it would always fail
func closeStdin() {
// Nop
}
// reassignStdout points stdout/stderr to our logfile on systems that do not
// support the Dup2 syscall
func reassignStdout() {
Tracef("Windows reassignStdout()")
if !stdOutsClosed {
// Close std* streams to prevent accidental output, they will be redirected to
// our logfile below
// Warning: this will hide panic() output, sorry Windows users
if err := os.Stderr.Close(); err != nil {
// Not considered fatal
Errorf("Failed to close os.Stderr during log setup")
}
if err := os.Stdin.Close(); err != nil {
// Not considered fatal
Errorf("Failed to close os.Stdin during log setup")
}
os.Stdout = logf
os.Stderr = logf
stdOutsClosed = true
}
}
+110
View File
@@ -0,0 +1,110 @@
package msghub
import (
"container/ring"
"context"
"time"
)
// Length of msghub operation queue
const opChanLen = 100
// 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 history buffer, followed by new messages
type Listener interface {
Receive(msg Message) error
}
// Hub relays messages on to its listeners
type Hub struct {
// history buffer, points next Message to write. Proceeding non-nil entry is oldest Message
history *ring.Ring
listeners map[Listener]struct{} // listeners interested in new messages
opChan chan func(h *Hub) // operations queued for this actor
}
// New constructs a new Hub which will cache historyLen 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, historyLen int) *Hub {
h := &Hub{
history: ring.New(historyLen),
listeners: make(map[Listener]struct{}),
opChan: make(chan func(h *Hub), opChanLen),
}
go func() {
for {
select {
case <-ctx.Done():
// Shutdown
close(h.opChan)
return
case op := <-h.opChan:
op(h)
}
}
}()
return h
}
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
// history buffer and then relayed to all registered listeners.
func (hub *Hub) Dispatch(msg Message) {
hub.opChan <- func(h *Hub) {
if h.history != nil {
// Add to history buffer
h.history.Value = msg
h.history = h.history.Next()
// Deliver message to all listeners, removing listeners if they return an error
for l := range h.listeners {
if err := l.Receive(msg); err != nil {
delete(h.listeners, l)
}
}
}
}
}
// AddListener registers a listener to receive broadcasted messages.
func (hub *Hub) AddListener(l Listener) {
hub.opChan <- func(h *Hub) {
// Playback log
h.history.Do(func(v interface{}) {
if v != nil {
l.Receive(v.(Message))
}
})
// Add to listeners
h.listeners[l] = struct{}{}
}
}
// RemoveListener deletes a listener registration, it will cease to receive messages.
func (hub *Hub) RemoveListener(l Listener) {
hub.opChan <- func(h *Hub) {
delete(h.listeners, l)
}
}
// Sync blocks until the msghub has processed its queue up to this point, useful
// for unit tests.
func (hub *Hub) Sync() {
done := make(chan struct{})
hub.opChan <- func(h *Hub) {
close(done)
}
<-done
}
+255
View File
@@ -0,0 +1,255 @@
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 TestHubZeroLen(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 0)
m := Message{}
for i := 0; i < 100; i++ {
hub.Dispatch(m)
}
// Just making sure Hub doesn't panic
}
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.Dispatch(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.Dispatch(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.Dispatch(m)
hub.RemoveListener(l)
hub.Dispatch(m)
hub.Sync()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * 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.Dispatch(m)
hub.Dispatch(m)
hub.Dispatch(m)
hub.Dispatch(m)
hub.Sync()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * time.Millisecond):
// Expected result, no overflow
}
}
func TestHubHistoryReplay(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.Dispatch(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 (history)
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 TestHubHistoryReplayWrap(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.Dispatch(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 (history)
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.Dispatch(m)
hub.Sync()
cancel()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * time.Millisecond):
// Expected result, no overflow
}
}
+201
View File
@@ -0,0 +1,201 @@
package rest
import (
"fmt"
"io"
"net/http"
"crypto/md5"
"encoding/hex"
"io/ioutil"
"strconv"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
func MailboxListV1(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
messages, err := mb.GetMessages()
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
log.Tracef("Got %v messsages", len(messages))
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
}
}
return web.RenderJSON(w, jmessages)
}
// MailboxShowV1 renders a particular message from a mailbox
func MailboxShowV1(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
msg, err := mb.GetMessage(id)
if err == datastore.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)
}
header, err := msg.ReadHeader()
if err != nil {
return fmt.Errorf("ReadHeader(%q) failed: %v", id, err)
}
mime, err := msg.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
attachments := make([]*model.JSONMessageAttachmentV1, len(mime.Attachments))
for i, att := range mime.Attachments {
var content []byte
content, err = ioutil.ReadAll(att)
var checksum = md5.Sum(content)
attachments[i] = &model.JSONMessageAttachmentV1{
ContentType: att.ContentType,
FileName: att.FileName,
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
MD5: hex.EncodeToString(checksum[:]),
}
}
return web.RenderJSON(w,
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
Header: header.Header,
Body: &model.JSONMessageBodyV1{
Text: mime.Text,
HTML: mime.HTML,
},
Attachments: attachments,
})
}
// MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
// Delete all messages
err = mb.Purge()
if err != nil {
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
}
log.Tracef("HTTP purged mailbox for %q", name)
return web.RenderJSON(w, "OK")
}
// MailboxSourceV1 displays the raw source of a message, including headers. Renders text/plain
func MailboxSourceV1(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
raw, err := message.ReadRaw()
if err != nil {
return fmt.Errorf("ReadRaw(%q) failed: %v", id, err)
}
w.Header().Set("Content-Type", "text/plain")
if _, err := io.WriteString(w, *raw); err != nil {
return err
}
return nil
}
// MailboxDeleteV1 removes a particular message from a mailbox
func MailboxDeleteV1(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
err = message.Delete()
if err != nil {
return fmt.Errorf("Delete(%q) failed: %v", id, err)
}
return web.RenderJSON(w, "OK")
}
+266
View File
@@ -0,0 +1,266 @@
package rest
import (
"encoding/json"
"fmt"
"io"
"net/mail"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
)
const (
baseURL = "http://localhost/api/v1"
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
headerKey = "header"
bodyKey = "body"
textKey = "text"
htmlKey = "html"
)
func TestRestMailboxList(t *testing.T) {
// Setup
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo@bar")
expectCode := 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test empty mailbox
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessages").Return([]datastore.Message{}, nil)
w, err = testRestGet(baseURL + "/mailbox/empty")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test MailboxFor error
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
// Test MailboxFor error
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test JSON message headers
data1 := &InputMessageData{
Mailbox: "good",
ID: "0001",
From: "from1",
To: []string{"to1"},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
}
data2 := &InputMessageData{
Mailbox: "good",
ID: "0002",
From: "from2",
To: []string{"to1"},
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)),
}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
msg2 := data2.MockMessage()
goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil)
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Check JSON
dec := json.NewDecoder(w.Body)
var result []interface{}
if err := dec.Decode(&result); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
if len(result) != 2 {
t.Errorf("Expected 2 results, got %v", len(result))
}
if errors := data1.CompareToJSONHeaderMap(result[0]); len(errors) > 0 {
t.Logf("%v", result[0])
for _, e := range errors {
t.Error(e)
}
}
if errors := data2.CompareToJSONHeaderMap(result[1]); len(errors) > 0 {
t.Logf("%v", result[1])
for _, e := range errors {
t.Error(e)
}
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
func TestRestMessage(t *testing.T) {
// Setup
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo@bar/0001")
expectCode := 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test requesting a message that does not exist
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist)
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
expectCode = 404
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test MailboxFor error
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error/0001")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
// Test GetMessage error
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2/0001")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test JSON message headers
data1 := &InputMessageData{
Mailbox: "good",
ID: "0001",
From: "from1",
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
Header: mail.Header{
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
"From": []string{"noreply@inbucket.org"},
},
Text: "This is some text",
HTML: "This is some HTML",
}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
goodbox.On("GetMessage", "0001").Return(msg1, nil)
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good/0001")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Check JSON
dec := json.NewDecoder(w.Body)
var result map[string]interface{}
if err := dec.Decode(&result); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
if errors := data1.CompareToJSONMessageMap(result); len(errors) > 0 {
t.Logf("%v", result)
for _, e := range errors {
t.Error(e)
}
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package client provides a basic REST client for Inbucket
package client
import (
"bytes"
"fmt"
"net/http"
"net/url"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1
type Client struct {
restClient
}
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
// "http://localhost:9000"
func New(baseURL string) (*Client, error) {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
c := &Client{
restClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: parsedURL,
},
}
return c, nil
}
// ListMailbox returns a list of messages for the requested mailbox
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
err = c.doJSON("GET", uri, &headers)
if err != nil {
return nil, err
}
for _, h := range headers {
h.client = c
}
return
}
// GetMessage returns the message details given a mailbox name and message ID.
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err = c.doJSON("GET", uri, &message)
if err != nil {
return nil, err
}
message.client = c
return
}
// GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
resp, err := c.do("GET", uri)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil,
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
return buf, err
}
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// MessageHeader represents an Inbucket message sans content
type MessageHeader struct {
*model.JSONMessageHeaderV1
client *Client
}
// GetMessage returns this message with content
func (h *MessageHeader) GetMessage() (message *Message, err error) {
return h.client.GetMessage(h.Mailbox, h.ID)
}
// GetSource returns the source for this message
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
return h.client.GetMessageSource(h.Mailbox, h.ID)
}
// Delete deletes this message from the mailbox
func (h *MessageHeader) Delete() error {
return h.client.DeleteMessage(h.Mailbox, h.ID)
}
// Message represents an Inbucket message including content
type Message struct {
*model.JSONMessageV1
client *Client
}
// GetSource returns the source for this message
func (m *Message) GetSource() (*bytes.Buffer, error) {
return m.client.GetMessageSource(m.Mailbox, m.ID)
}
// Delete deletes this message from the mailbox
func (m *Message) Delete() error {
return m.client.DeleteMessage(m.Mailbox, m.ID)
}
+323
View File
@@ -0,0 +1,323 @@
package client
import "testing"
func TestClientV1ListMailbox(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
_, _ = c.ListMailbox("testbox")
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1GetMessage(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
_, _ = c.GetMessage("testbox", "20170107T224128-0000")
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1GetMessageSource(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{
body: "message source",
}
c.client = mth
// Method under test
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
want = "message source"
got = source.String()
if got != want {
t.Errorf("Source == %q, want: %q", got, want)
}
}
func TestClientV1DeleteMessage(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
err = c.DeleteMessage("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1PurgeMailbox(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
err = c.PurgeMailbox("testbox")
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1MessageHeader(t *testing.T) {
var want, got string
response := `[
{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
}
]`
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{body: response}
c.client = mth
// Method under test
headers, err := c.ListMailbox("testbox")
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
if len(headers) != 1 {
t.Fatalf("len(headers) == %v, want 1", len(headers))
}
header := headers[0]
want = "mailbox1"
got = header.Mailbox
if got != want {
t.Errorf("Mailbox == %q, want %q", got, want)
}
want = "id1"
got = header.ID
if got != want {
t.Errorf("ID == %q, want %q", got, want)
}
want = "from1"
got = header.From
if got != want {
t.Errorf("From == %q, want %q", got, want)
}
want = "subject1"
got = header.Subject
if got != want {
t.Errorf("Subject == %q, want %q", got, want)
}
// Test MessageHeader.Delete()
mth.body = ""
err = header.Delete()
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetSource()
mth.body = "source1"
_, err = header.GetSource()
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetMessage()
mth.body = `{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
}`
message, err := header.GetMessage()
if err != nil {
t.Fatal(err)
}
if message == nil {
t.Fatalf("message was nil, wanted a value")
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test Message.Delete()
mth.body = ""
err = message.Delete()
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetSource()
mth.body = "source1"
_, err = message.GetSource()
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
+59
View File
@@ -0,0 +1,59 @@
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// httpClient allows http.Client to be mocked for tests
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
// Generic REST restClient
type restClient struct {
client httpClient
baseURL *url.URL
}
// do performs an HTTP request with this client and returns the response
func (c *restClient) do(method, uri string) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
// Build the request
req, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, err
}
// Send the request
return c.client.Do(req)
}
// doGet performs an HTTP request with this client and marshalls the JSON response into v
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
+125
View File
@@ -0,0 +1,125 @@
package client
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"testing"
)
const baseURLStr = "http://test.local:8080"
var baseURL *url.URL
func init() {
var err error
baseURL, err = url.Parse(baseURLStr)
if err != nil {
panic(err)
}
}
type mockHTTPClient struct {
req *http.Request
statusCode int
body string
}
func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) {
m.req = req
if m.statusCode == 0 {
m.statusCode = 200
}
resp = &http.Response{
StatusCode: m.statusCode,
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
}
return
}
func TestDo(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
_, err := c.do("POST", "/dopost")
if err != nil {
t.Fatal(err)
}
want = "POST"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/dopost"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestDoJSON(t *testing.T) {
var want, got string
mth := &mockHTTPClient{
body: `{"foo": "bar"}`,
}
c := &restClient{mth, baseURL}
var v map[string]interface{}
err := c.doJSON("GET", "/doget", &v)
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/doget"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
want = "bar"
if val, ok := v["foo"]; ok {
got = val.(string)
if got != want {
t.Errorf("map[foo] == %q, want: %q", got, want)
}
} else {
t.Errorf("Map did not contain key foo, want: %q", want)
}
}
func TestDoJSONNilV(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
err := c.doJSON("GET", "/doget", nil)
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/doget"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
+45
View File
@@ -0,0 +1,45 @@
package model
import (
"net/mail"
"time"
)
// JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 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"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
type JSONMessageV1 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"`
Body *JSONMessageBodyV1 `json:"body"`
Header mail.Header `json:"header"`
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`
DownloadLink string `json:"download-link"`
ViewLink string `json:"view-link"`
MD5 string `json:"md5"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`
}
+23
View File
@@ -0,0 +1,23 @@
package rest
import "github.com/gorilla/mux"
import "github.com/jhillyerd/inbucket/pkg/server/web"
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
// API v1
r.Path("/api/v1/mailbox/{name}").Handler(
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/api/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
}
+195
View File
@@ -0,0 +1,195 @@
package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
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 // Global message hub
c chan msghub.Message // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes
}
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
ml := &msgListener{
hub: hub,
c: make(chan msghub.Message, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message
func (ml *msgListener) Receive(msg msghub.Message) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match mailbox name
return nil
}
ml.c <- msg
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
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 *web.Context) (err error) {
// Upgrade to Websocket
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.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
}
func MonitorMailboxMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
ml := newMsgListener(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package rest
import (
"bytes"
"fmt"
"log"
"net/http"
"net/http/httptest"
"net/mail"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
)
type InputMessageData struct {
Mailbox, ID, From, Subject string
To []string
Date time.Time
Size int
Header mail.Header
HTML, Text string
}
func (d *InputMessageData) MockMessage() *datastore.MockMessage {
msg := &datastore.MockMessage{}
msg.On("ID").Return(d.ID)
msg.On("From").Return(d.From)
msg.On("To").Return(d.To)
msg.On("Subject").Return(d.Subject)
msg.On("Date").Return(d.Date)
msg.On("Size").Return(d.Size)
gomsg := &mail.Message{
Header: d.Header,
}
msg.On("ReadHeader").Return(gomsg, nil)
body := &enmime.Envelope{
Text: d.Text,
HTML: d.HTML,
}
msg.On("ReadBody").Return(body, nil)
return msg
}
// isJSONStringEqual is a utility function to return a nicely formatted message when
// comparing a string to a value received from a JSON map.
func isJSONStringEqual(key, expected string, received interface{}) (message string, ok bool) {
if value, ok := received.(string); ok {
if expected == value {
return "", true
}
return fmt.Sprintf("Expected value of key %v to be %q, got %q", key, expected, value), false
}
return fmt.Sprintf("Expected value of key %v to be a string, got %T", key, received), false
}
// isJSONNumberEqual is a utility function to return a nicely formatted message when
// comparing an float64 to a value received from a JSON map.
func isJSONNumberEqual(key string, expected float64, received interface{}) (message string, ok bool) {
if value, ok := received.(float64); ok {
if expected == value {
return "", true
}
return fmt.Sprintf("Expected %v to be %v, got %v", key, expected, value), false
}
return fmt.Sprintf("Expected %v to be a string, got %T", key, received), false
}
// CompareToJSONHeaderMap compares InputMessageData to a header map decoded from JSON,
// returning a list of things that did not match.
func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []string) {
if m, ok := json.(map[string]interface{}); ok {
if msg, ok := isJSONStringEqual(mailboxKey, d.Mailbox, m[mailboxKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(idKey, d.ID, m[idKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(fromKey, d.From, m[fromKey]); !ok {
errors = append(errors, msg)
}
for i, inputTo := range d.To {
if msg, ok := isJSONStringEqual(toKey, inputTo, m[toKey].([]interface{})[i]); !ok {
errors = append(errors, msg)
}
}
if msg, ok := isJSONStringEqual(subjectKey, d.Subject, m[subjectKey]); !ok {
errors = append(errors, msg)
}
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
errors = append(errors, msg)
}
return errors
}
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
}
// CompareToJSONMessageMap compares InputMessageData to a message map decoded from JSON,
// returning a list of things that did not match.
func (d *InputMessageData) CompareToJSONMessageMap(json interface{}) (errors []string) {
// We need to check the same values as header first
errors = d.CompareToJSONHeaderMap(json)
if m, ok := json.(map[string]interface{}); ok {
// Get nested body map
if m[bodyKey] != nil {
if body, ok := m[bodyKey].(map[string]interface{}); ok {
if msg, ok := isJSONStringEqual(textKey, d.Text, body[textKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(htmlKey, d.HTML, body[htmlKey]); !ok {
errors = append(errors, msg)
}
} else {
panic(fmt.Sprintf("Expected map[string]interface{} in json key %q, got %T",
bodyKey, m[bodyKey]))
}
} else {
errors = append(errors, fmt.Sprintf("Expected body in JSON %q but it was nil", bodyKey))
}
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
errors = append(errors, msg)
}
// Get nested header map
if m[headerKey] != nil {
if header, ok := m[headerKey].(map[string]interface{}); ok {
// Loop over input (expected) header names
for name, keyInputHeaders := range d.Header {
// Make sure expected header name exists in received JSON
if keyOutputVals, ok := header[name]; ok {
if keyOutputHeaders, ok := keyOutputVals.([]interface{}); ok {
// Loop over input (expected) header values
for _, inputHeader := range keyInputHeaders {
hasValue := false
// Look for expected value in received headers
for _, outputHeader := range keyOutputHeaders {
if inputHeader == outputHeader {
hasValue = true
break
}
}
if !hasValue {
errors = append(errors, fmt.Sprintf(
"JSON %v[%q] missing value %q", headerKey, name, inputHeader))
}
}
} else {
// keyOutputValues was not a slice of interface{}
panic(fmt.Sprintf("Expected []interface{} in %v[%q], got %T", headerKey,
name, keyOutputVals))
}
} else {
errors = append(errors, fmt.Sprintf("JSON %v missing key %q", headerKey, name))
}
}
}
} else {
errors = append(errors, fmt.Sprintf("Expected header in JSON %q but it was nil", headerKey))
}
} else {
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
}
return errors
}
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func setupWebServer(ds datastore.DataStore) *bytes.Buffer {
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
// Have to reset default mux to prevent duplicate routes
http.DefaultServeMux = http.NewServeMux()
cfg := config.WebConfig{
TemplateDir: "../themes/bootstrap/templates",
PublicDir: "../themes/bootstrap/public",
}
shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, ds, &msghub.Hub{})
SetupRoutes(web.Router)
return buf
}
+656
View File
@@ -0,0 +1,656 @@
package pop3
import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// State tracks the current mode of our POP3 state machine
type State int
const (
// AUTHORIZATION state: the client must now identify and authenticate
AUTHORIZATION State = iota
// TRANSACTION state: mailbox open, client may now issue commands
TRANSACTION
// QUIT state: client requests us to end session
QUIT
)
func (s State) String() string {
switch s {
case AUTHORIZATION:
return "AUTHORIZATION"
case TRANSACTION:
return "TRANSACTION"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"QUIT": true,
"STAT": true,
"LIST": true,
"RETR": true,
"DELE": true,
"NOOP": true,
"RSET": true,
"TOP": true,
"UIDL": true,
"USER": true,
"PASS": true,
"APOP": true,
"CAPA": true,
}
// Session defines an active POP3 session
type Session struct {
server *Server // Reference to the server we belong to
id int // Session ID number
conn net.Conn // Our network connection
remoteHost string // IP address of client
sendError error // Used to bail out of read loop on send error
state State // Current session state
reader *bufio.Reader // Buffered reader for our net conn
user string // Mailbox name
mailbox datastore.Mailbox // Mailbox instance
messages []datastore.Message // Slice of messages in mailbox
retain []bool // Messages to retain upon UPDATE (true=retain)
msgCount int // Number of undeleted messages
}
// NewSession creates a new POP3 session
func NewSession(server *Server, id int, conn net.Conn) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{server: server, id: id, conn: conn, state: AUTHORIZATION,
reader: reader, remoteHost: host}
}
func (ses *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", ses.id, ses.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
log.Infof("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id)
//expConnectsCurrent.Add(1)
defer func() {
if err := conn.Close(); err != nil {
log.Errorf("Error closing POP3 connection for <%v>: %v", id, err)
}
s.waitgroup.Done()
//expConnectsCurrent.Add(-1)
}()
ses := NewSession(s, id, conn)
ses.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
time.Now().Unix(), s.domain))
// This is our command reading loop
for ses.state != QUIT && ses.sendError == nil {
line, err := ses.readLine()
if err == nil {
if cmd, arg, ok := ses.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ses.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ses.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ses.logWarn("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
ses.send("+OK Capability list follows")
ses.send("TOP")
ses.send("USER")
ses.send("UIDL")
ses.send("IMPLEMENTATION Inbucket")
ses.send(".")
continue
}
// Send command to handler for current state
switch ses.state {
case AUTHORIZATION:
ses.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ses.transactionHandler(cmd, arg)
continue
}
ses.logError("Session entered unexpected state %v", ses.state)
break
} else {
ses.send("-ERR Syntax error, command garbled")
}
} else {
// readLine() returned an error
if err == io.EOF {
switch ses.state {
case AUTHORIZATION:
// EOF is common here
ses.logInfo("Client closed connection (state %v)", ses.state)
default:
ses.logWarn("Got EOF while in state %v", ses.state)
}
break
}
// not an EOF
ses.logWarn("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ses.send("-ERR Idle timeout, bye bye")
break
}
}
ses.send("-ERR Connection error, sorry")
break
}
}
if ses.sendError != nil {
ses.logWarn("Network send error: %v", ses.sendError)
}
ses.logInfo("Closing connection")
}
// AUTHORIZATION state
func (ses *Session) authorizationHandler(cmd string, args []string) {
switch cmd {
case "QUIT":
ses.send("+OK Goodnight and good luck")
ses.enterState(QUIT)
case "USER":
if len(args) > 0 {
ses.user = args[0]
ses.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", ses.user))
} else {
ses.send("-ERR Missing username argument")
}
case "PASS":
if ses.user == "" {
ses.ooSeq(cmd)
} else {
var err error
ses.mailbox, err = ses.server.dataStore.MailboxFor(ses.user)
if err != nil {
ses.logError("Failed to open mailbox for %v", ses.user)
ses.send(fmt.Sprintf("-ERR Failed to open mailbox for %v", ses.user))
ses.enterState(QUIT)
return
}
ses.loadMailbox()
ses.send(fmt.Sprintf("+OK Found %v messages for %v", ses.msgCount, ses.user))
ses.enterState(TRANSACTION)
}
case "APOP":
if len(args) != 2 {
ses.logWarn("Expected two arguments for APOP")
ses.send("-ERR APOP requires two arguments")
return
}
ses.user = args[0]
var err error
ses.mailbox, err = ses.server.dataStore.MailboxFor(ses.user)
if err != nil {
ses.logError("Failed to open mailbox for %v", ses.user)
ses.send(fmt.Sprintf("-ERR Failed to open mailbox for %v", ses.user))
ses.enterState(QUIT)
return
}
ses.loadMailbox()
ses.send(fmt.Sprintf("+OK Found %v messages for %v", ses.msgCount, ses.user))
ses.enterState(TRANSACTION)
default:
ses.ooSeq(cmd)
}
}
// TRANSACTION state
func (ses *Session) transactionHandler(cmd string, args []string) {
switch cmd {
case "STAT":
if len(args) != 0 {
ses.logWarn("STAT got an unexpected argument")
ses.send("-ERR STAT command must have no arguments")
return
}
var count int
var size int64
for i, msg := range ses.messages {
if ses.retain[i] {
count++
size += msg.Size()
}
}
ses.send(fmt.Sprintf("+OK %v %v", count, size))
case "LIST":
if len(args) > 1 {
ses.logWarn("LIST command had more than 1 argument")
ses.send("-ERR LIST command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
ses.logWarn("LIST command argument was not an integer")
ses.send("-ERR LIST command requires an integer argument")
return
}
if msgNum < 1 {
ses.logWarn("LIST command argument was less than 1")
ses.send("-ERR LIST argument must be greater than 0")
return
}
if int(msgNum) > len(ses.messages) {
ses.logWarn("LIST command argument was greater than number of messages")
ses.send("-ERR LIST argument must not exceed the number of messages")
return
}
if !ses.retain[msgNum-1] {
ses.logWarn("Client tried to LIST a message it had deleted")
ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].Size()))
} else {
ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount))
for i, msg := range ses.messages {
if ses.retain[i] {
ses.send(fmt.Sprintf("%v %v", i+1, msg.Size()))
}
}
ses.send(".")
}
case "UIDL":
if len(args) > 1 {
ses.logWarn("UIDL command had more than 1 argument")
ses.send("-ERR UIDL command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
ses.logWarn("UIDL command argument was not an integer")
ses.send("-ERR UIDL command requires an integer argument")
return
}
if msgNum < 1 {
ses.logWarn("UIDL command argument was less than 1")
ses.send("-ERR UIDL argument must be greater than 0")
return
}
if int(msgNum) > len(ses.messages) {
ses.logWarn("UIDL command argument was greater than number of messages")
ses.send("-ERR UIDL argument must not exceed the number of messages")
return
}
if !ses.retain[msgNum-1] {
ses.logWarn("Client tried to UIDL a message it had deleted")
ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].ID()))
} else {
ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount))
for i, msg := range ses.messages {
if ses.retain[i] {
ses.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
}
}
ses.send(".")
}
case "DELE":
if len(args) != 1 {
ses.logWarn("DELE command had invalid number of arguments")
ses.send("-ERR DELE command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
ses.logWarn("DELE command argument was not an integer")
ses.send("-ERR DELE command requires an integer argument")
return
}
if msgNum < 1 {
ses.logWarn("DELE command argument was less than 1")
ses.send("-ERR DELE argument must be greater than 0")
return
}
if int(msgNum) > len(ses.messages) {
ses.logWarn("DELE command argument was greater than number of messages")
ses.send("-ERR DELE argument must not exceed the number of messages")
return
}
if ses.retain[msgNum-1] {
ses.retain[msgNum-1] = false
ses.msgCount--
ses.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
} else {
ses.logWarn("Client tried to DELE an already deleted message")
ses.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum))
}
case "RETR":
if len(args) != 1 {
ses.logWarn("RETR command had invalid number of arguments")
ses.send("-ERR RETR command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
ses.logWarn("RETR command argument was not an integer")
ses.send("-ERR RETR command requires an integer argument")
return
}
if msgNum < 1 {
ses.logWarn("RETR command argument was less than 1")
ses.send("-ERR RETR argument must be greater than 0")
return
}
if int(msgNum) > len(ses.messages) {
ses.logWarn("RETR command argument was greater than number of messages")
ses.send("-ERR RETR argument must not exceed the number of messages")
return
}
ses.send(fmt.Sprintf("+OK %v bytes follows", ses.messages[msgNum-1].Size()))
ses.sendMessage(ses.messages[msgNum-1])
case "TOP":
if len(args) != 2 {
ses.logWarn("TOP command had invalid number of arguments")
ses.send("-ERR TOP command requires two arguments")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
ses.logWarn("TOP command first argument was not an integer")
ses.send("-ERR TOP command requires an integer argument")
return
}
if msgNum < 1 {
ses.logWarn("TOP command first argument was less than 1")
ses.send("-ERR TOP first argument must be greater than 0")
return
}
if int(msgNum) > len(ses.messages) {
ses.logWarn("TOP command first argument was greater than number of messages")
ses.send("-ERR TOP first argument must not exceed the number of messages")
return
}
var lines int64
lines, err = strconv.ParseInt(args[1], 10, 32)
if err != nil {
ses.logWarn("TOP command second argument was not an integer")
ses.send("-ERR TOP command requires an integer argument")
return
}
if lines < 0 {
ses.logWarn("TOP command second argument was negative")
ses.send("-ERR TOP second argument must be non-negative")
return
}
ses.send("+OK Top of message follows")
ses.sendMessageTop(ses.messages[msgNum-1], int(lines))
case "QUIT":
ses.send("+OK We will process your deletes")
ses.processDeletes()
ses.enterState(QUIT)
case "NOOP":
ses.send("+OK I have sucessfully done nothing")
case "RSET":
// Reset session, don't actually delete anything I told you to
ses.logTrace("Resetting session state on RSET request")
ses.reset()
ses.send("+OK Session reset")
default:
ses.ooSeq(cmd)
}
}
// Send the contents of the message to the client
func (ses *Session) sendMessage(msg datastore.Message) {
reader, err := msg.RawReader()
if err != nil {
ses.logError("Failed to read message for RETR command")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
ses.logError("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
}
ses.send(line)
}
if err = scanner.Err(); err != nil {
ses.logError("Failed to read message for RETR command")
ses.send(".")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
ses.send(".")
}
// Send the headers plus the top N lines to the client
func (ses *Session) sendMessageTop(msg datastore.Message, lineCount int) {
reader, err := msg.RawReader()
if err != nil {
ses.logError("Failed to read message for RETR command")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
ses.logError("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
inBody := false
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
}
if inBody {
// Check if we need to send anymore lines
if lineCount < 1 {
break
} else {
lineCount--
}
} else {
if line == "" {
// We've hit the end of the header
inBody = true
}
}
ses.send(line)
}
if err = scanner.Err(); err != nil {
ses.logError("Failed to read message for RETR command")
ses.send(".")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
ses.send(".")
}
// Load the users mailbox
func (ses *Session) loadMailbox() {
var err error
ses.messages, err = ses.mailbox.GetMessages()
if err != nil {
ses.logError("Failed to load messages for %v", ses.user)
}
ses.retainAll()
}
// Reset retain flag to true for all messages
func (ses *Session) retainAll() {
ses.retain = make([]bool, len(ses.messages))
for i := range ses.retain {
ses.retain[i] = true
}
ses.msgCount = len(ses.messages)
}
// This would be considered the "UPDATE" state in the RFC, but it does not fit
// with our state-machine design here, since no commands are accepted - it just
// indicates that the session was closed cleanly and that deletes should be
// processed.
func (ses *Session) processDeletes() {
ses.logInfo("Processing deletes")
for i, msg := range ses.messages {
if !ses.retain[i] {
ses.logTrace("Deleting %v", msg)
if err := msg.Delete(); err != nil {
ses.logWarn("Error deleting %v: %v", msg, err)
}
}
}
}
func (ses *Session) enterState(state State) {
ses.state = state
ses.logTrace("Entering state %v", state)
}
// Calculate the next read or write deadline based on maxIdleSeconds
func (ses *Session) nextDeadline() time.Time {
return time.Now().Add(time.Duration(ses.server.maxIdleSeconds) * time.Second)
}
// Send requested message, store errors in Session.sendError
func (ses *Session) send(msg string) {
if err := ses.conn.SetWriteDeadline(ses.nextDeadline()); err != nil {
ses.sendError = err
return
}
if _, err := fmt.Fprint(ses.conn, msg+"\r\n"); err != nil {
ses.sendError = err
ses.logWarn("Failed to send: '%v'", msg)
return
}
ses.logTrace(">> %v >>", msg)
}
// readByteLine reads a line of input into the provided buffer. Does
// not reset the Buffer - please do so prior to calling.
func (ses *Session) readByteLine(buf *bytes.Buffer) error {
if err := ses.conn.SetReadDeadline(ses.nextDeadline()); err != nil {
return err
}
for {
line, err := ses.reader.ReadBytes('\r')
if err != nil {
return err
}
if _, err = buf.Write(line); err != nil {
return err
}
// Read the next byte looking for '\n'
c, err := ses.reader.ReadByte()
if err != nil {
return err
}
if err := buf.WriteByte(c); err != nil {
return err
}
if c == '\n' {
// We've reached the end of the line, return
return nil
}
// Else, keep looking
}
// Should be unreachable
}
// Reads a line of input
func (ses *Session) readLine() (line string, err error) {
if err = ses.conn.SetReadDeadline(ses.nextDeadline()); err != nil {
return "", err
}
line, err = ses.reader.ReadString('\n')
if err != nil {
return "", err
}
ses.logTrace("<< %v <<", strings.TrimRight(line, "\r\n"))
return line, nil
}
func (ses *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", nil, true
}
words := strings.Split(line, " ")
return strings.ToUpper(words[0]), words[1:], true
}
func (ses *Session) reset() {
ses.retainAll()
}
func (ses *Session) ooSeq(cmd string) {
ses.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd))
ses.logWarn("Wasn't expecting %v here", cmd)
}
// Session specific logging methods
func (ses *Session) logTrace(msg string, args ...interface{}) {
log.Tracef("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logInfo(msg string, args ...interface{}) {
log.Infof("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logWarn(msg string, args ...interface{}) {
// Update metrics
//expWarnsTotal.Add(1)
log.Warnf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logError(msg string, args ...interface{}) {
// Update metrics
//expErrorsTotal.Add(1)
log.Errorf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
+123
View File
@@ -0,0 +1,123 @@
package pop3
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Server defines an instance of our POP3 server
type Server struct {
host string
domain string
maxIdleSeconds int
dataStore datastore.DataStore
listener net.Listener
globalShutdown chan bool
waitgroup *sync.WaitGroup
}
// New creates a new Server struct
func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server {
return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
dataStore: ds,
maxIdleSeconds: cfg.MaxIdleSeconds,
globalShutdown: shutdownChan,
waitgroup: new(sync.WaitGroup),
}
}
// Start the server and listen for connections
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.Errorf("POP3 Failed to build tcp4 address: %v", err)
s.emergencyShutdown()
return
}
log.Infof("POP3 listening on TCP4 %v", addr)
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.Errorf("POP3 failed to start tcp4 listener: %v", err)
s.emergencyShutdown()
return
}
// Listener go routine
go s.serve(ctx)
// Wait for shutdown
select {
case _ = <-ctx.Done():
}
log.Tracef("POP3 shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit
if err := s.listener.Close(); err != nil {
log.Errorf("Error closing POP3 listener: %v", err)
}
}
// serve is the listen/accept loop
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections
var tempDelay time.Duration
for sid := 1; ; sid++ {
if conn, err := s.listener.Accept(); err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.Errorf("POP3 accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
} else {
// Permanent error
select {
case <-ctx.Done():
// POP3 is shutting down
return
default:
// Something went wrong
s.emergencyShutdown()
return
}
}
} else {
tempDelay = 0
s.waitgroup.Add(1)
go s.startSession(sid, conn)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close
s.waitgroup.Wait()
log.Tracef("POP3 connections have drained")
}
+628
View File
@@ -0,0 +1,628 @@
package smtp
import (
"bufio"
"bytes"
"container/list"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// State tracks the current mode of our SMTP state machine
type State int
const (
// GREET State: Waiting for HELO
GREET State = iota
// READY State: Got HELO, waiting for MAIL
READY
// MAIL State: Got MAIL, accepting RCPTs
MAIL
// DATA State: Got DATA, waiting for "."
DATA
// QUIT State: Client requested end of session
QUIT
)
const timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
func (s State) String() string {
switch s {
case GREET:
return "GREET"
case READY:
return "READY"
case MAIL:
return "MAIL"
case DATA:
return "DATA"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"HELO": true,
"EHLO": true,
"MAIL": true,
"RCPT": true,
"DATA": true,
"RSET": true,
"SEND": true,
"SOML": true,
"SAML": true,
"VRFY": true,
"EXPN": true,
"HELP": true,
"NOOP": true,
"QUIT": true,
"TURN": true,
}
// recipientDetails for message delivery
type recipientDetails struct {
address, localPart, domainPart string
mailbox datastore.Mailbox
}
// Session holds the state of an SMTP session
type Session struct {
server *Server
id int
conn net.Conn
remoteDomain string
remoteHost string
sendError error
state State
reader *bufio.Reader
from string
recipients *list.List
}
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{server: server, id: id, conn: conn, state: GREET, reader: reader, remoteHost: host}
}
func (ss *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", ss.id, ss.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
log.Infof("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id)
expConnectsCurrent.Add(1)
defer func() {
if err := conn.Close(); err != nil {
log.Errorf("Error closing connection for <%v>: %v", id, err)
}
s.waitgroup.Done()
expConnectsCurrent.Add(-1)
}()
ss := NewSession(s, id, conn)
ss.greet()
// This is our command reading loop
for ss.state != QUIT && ss.sendError == nil {
if ss.state == DATA {
// Special case, does not use SMTP command format
ss.dataHandler()
continue
}
line, err := ss.readLine()
if err == nil {
if cmd, arg, ok := ss.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ss.send("500 Speak up")
continue
}
if !commands[cmd] {
ss.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd))
ss.logWarn("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN":
// These commands are not implemented in any state
ss.send(fmt.Sprintf("502 %v command not implemented", cmd))
ss.logWarn("Command %v not implemented by Inbucket", cmd)
continue
case "VRFY":
ss.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ss.send("250 I have sucessfully done nothing")
continue
case "RSET":
// Reset session
ss.logTrace("Resetting session state on RSET request")
ss.reset()
ss.send("250 Session reset")
continue
case "QUIT":
ss.send("221 Goodnight and good luck")
ss.enterState(QUIT)
continue
}
// Send command to handler for current state
switch ss.state {
case GREET:
ss.greetHandler(cmd, arg)
continue
case READY:
ss.readyHandler(cmd, arg)
continue
case MAIL:
ss.mailHandler(cmd, arg)
continue
}
ss.logError("Session entered unexpected state %v", ss.state)
break
} else {
ss.send("500 Syntax error, command garbled")
}
} else {
// readLine() returned an error
if err == io.EOF {
switch ss.state {
case GREET, READY:
// EOF is common here
ss.logInfo("Client closed connection (state %v)", ss.state)
default:
ss.logWarn("Got EOF while in state %v", ss.state)
}
break
}
// not an EOF
ss.logWarn("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ss.send("221 Idle timeout, bye bye")
break
}
}
ss.send("221 Connection error, sorry")
break
}
}
if ss.sendError != nil {
ss.logWarn("Network send error: %v", ss.sendError)
}
ss.logInfo("Closing connection")
}
// GREET state -> waiting for HELO
func (ss *Session) greetHandler(cmd string, arg string) {
switch cmd {
case "HELO":
domain, err := parseHelloArgument(arg)
if err != nil {
ss.send("501 Domain/address argument required for HELO")
return
}
ss.remoteDomain = domain
ss.send("250 Great, let's get this show on the road")
ss.enterState(READY)
case "EHLO":
domain, err := parseHelloArgument(arg)
if err != nil {
ss.send("501 Domain/address argument required for EHLO")
return
}
ss.remoteDomain = domain
ss.send("250-Great, let's get this show on the road")
ss.send("250-8BITMIME")
ss.send(fmt.Sprintf("250 SIZE %v", ss.server.maxMessageBytes))
ss.enterState(READY)
default:
ss.ooSeq(cmd)
}
}
func parseHelloArgument(arg string) (string, error) {
domain := arg
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
domain = arg[:idx]
}
if domain == "" {
return "", fmt.Errorf("Invalid domain")
}
return domain, nil
}
// READY state -> waiting for MAIL
func (ss *Session) readyHandler(cmd string, arg string) {
if cmd == "MAIL" {
// Match FROM, while accepting '>' as quoted pair and in double quoted strings
// (?i) makes the regex case insensitive, (?:) is non-grouping sub-match
re := regexp.MustCompile("(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
m := re.FindStringSubmatch(arg)
if m == nil {
ss.send("501 Was expecting MAIL arg syntax of FROM:<address>")
ss.logWarn("Bad MAIL argument: %q", arg)
return
}
from := m[1]
if _, _, err := stringutil.ParseEmailAddress(from); err != nil {
ss.send("501 Bad sender address syntax")
ss.logWarn("Bad address as MAIL arg: %q, %s", from, err)
return
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := ss.parseArgs(m[2])
if !ok {
ss.send("501 Unable to parse MAIL ESMTP parameters")
ss.logWarn("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
ss.send("501 Unable to parse SIZE as an integer")
ss.logWarn("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > ss.server.maxMessageBytes {
ss.send("552 Max message size exceeded")
ss.logWarn("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
ss.from = from
ss.recipients = list.New()
ss.logInfo("Mail from: %v", from)
ss.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
ss.enterState(MAIL)
} else {
ss.ooSeq(cmd)
}
}
// MAIL state -> waiting for RCPTs followed by DATA
func (ss *Session) mailHandler(cmd string, arg string) {
switch cmd {
case "RCPT":
if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") {
ss.send("501 Was expecting RCPT arg syntax of TO:<address>")
ss.logWarn("Bad RCPT argument: %q", arg)
return
}
// This trim is probably too forgiving
recip := strings.Trim(arg[3:], "<> ")
if _, _, err := stringutil.ParseEmailAddress(recip); err != nil {
ss.send("501 Bad recipient address syntax")
ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err)
return
}
if ss.recipients.Len() >= ss.server.maxRecips {
ss.logWarn("Maximum limit of %v recipients reached", ss.server.maxRecips)
ss.send(fmt.Sprintf("552 Maximum limit of %v recipients reached", ss.server.maxRecips))
return
}
ss.recipients.PushBack(recip)
ss.logInfo("Recipient: %v", recip)
ss.send(fmt.Sprintf("250 I'll make sure <%v> gets this", recip))
return
case "DATA":
if arg != "" {
ss.send("501 DATA command should not have any arguments")
ss.logWarn("Got unexpected args on DATA: %q", arg)
return
}
if ss.recipients.Len() > 0 {
// We have recipients, go to accept data
ss.enterState(DATA)
return
}
// DATA out of sequence
ss.ooSeq(cmd)
return
}
ss.ooSeq(cmd)
}
// DATA
func (ss *Session) dataHandler() {
recipients := make([]recipientDetails, 0, ss.recipients.Len())
// Get a Mailbox and a new Message for each recipient
msgSize := 0
if ss.server.storeMessages {
for e := ss.recipients.Front(); e != nil; e = e.Next() {
recip := e.Value.(string)
local, domain, err := stringutil.ParseEmailAddress(recip)
if err != nil {
ss.logError("Failed to parse address for %q", recip)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip))
ss.reset()
return
}
if strings.ToLower(domain) != ss.server.domainNoStore {
// Not our "no store" domain, so store the message
mb, err := ss.server.dataStore.MailboxFor(local)
if err != nil {
ss.logError("Failed to open mailbox for %q: %s", local, err)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", local))
ss.reset()
return
}
recipients = append(recipients, recipientDetails{recip, local, domain, mb})
} else {
log.Tracef("Not storing message for %q", recip)
}
}
}
ss.send("354 Start mail input; end with <CRLF>.<CRLF>")
var lineBuf bytes.Buffer
msgBuf := make([][]byte, 0, 1024)
for {
lineBuf.Reset()
err := ss.readByteLine(&lineBuf)
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ss.send("221 Idle timeout, bye bye")
}
}
ss.logWarn("Error: %v while reading", err)
ss.enterState(QUIT)
return
}
line := lineBuf.Bytes()
// ss.logTrace("DATA: %q", line)
if string(line) == ".\r\n" || string(line) == ".\n" {
// Mail data complete
if ss.server.storeMessages {
// Create a message for each valid recipient
for _, r := range recipients {
// TODO temporary hack to fix #77 until datastore revamp
mu, err := ss.server.dataStore.LockFor(r.localPart)
if err != nil {
ss.logError("Failed to get lock for %q: %s", r.localPart, err)
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
mu.Lock()
ok := ss.deliverMessage(r, msgBuf)
mu.Unlock()
if ok {
expReceivedTotal.Add(1)
} else {
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
}
} else {
expReceivedTotal.Add(1)
}
ss.send("250 Mail accepted for delivery")
ss.logInfo("Message size %v bytes", msgSize)
ss.reset()
return
}
// SMTP RFC says remove leading periods from input
if len(line) > 0 && line[0] == '.' {
line = line[1:]
}
// Second append copies line/lineBuf so we can reuse it
msgBuf = append(msgBuf, append([]byte{}, line...))
msgSize += len(line)
if msgSize > ss.server.maxMessageBytes {
// Max message size exceeded
ss.send("552 Maximum message size exceeded")
ss.logWarn("Max message size exceeded while in DATA")
ss.reset()
// Should really cleanup the crap on filesystem (after issue #23)
return
}
} // 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.Dispatch(broadcast)
return true
}
func (ss *Session) enterState(state State) {
ss.state = state
ss.logTrace("Entering state %v", state)
}
func (ss *Session) greet() {
ss.send(fmt.Sprintf("220 %v Inbucket SMTP ready", ss.server.domain))
}
// Calculate the next read or write deadline based on maxIdleSeconds
func (ss *Session) nextDeadline() time.Time {
return time.Now().Add(time.Duration(ss.server.maxIdleSeconds) * time.Second)
}
// Send requested message, store errors in Session.sendError
func (ss *Session) send(msg string) {
if err := ss.conn.SetWriteDeadline(ss.nextDeadline()); err != nil {
ss.sendError = err
return
}
if _, err := fmt.Fprint(ss.conn, msg+"\r\n"); err != nil {
ss.sendError = err
ss.logWarn("Failed to send: %q", msg)
return
}
ss.logTrace(">> %v >>", msg)
}
// readByteLine reads a line of input into the provided buffer. Does
// not reset the Buffer - please do so prior to calling.
func (ss *Session) readByteLine(buf io.Writer) error {
if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
return err
}
line, err := ss.reader.ReadBytes('\n')
if err != nil {
return err
}
_, err = buf.Write(line)
return err
}
// Reads a line of input
func (ss *Session) readLine() (line string, err error) {
if err = ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
return "", err
}
line, err = ss.reader.ReadString('\n')
if err != nil {
return "", err
}
ss.logTrace("<< %v <<", strings.TrimRight(line, "\r\n"))
return line, nil
}
func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
switch {
case l == 0:
return "", "", true
case l < 4:
ss.logWarn("Command too short: %q", line)
return "", "", false
case l == 4:
return strings.ToUpper(line), "", true
case l == 5:
// Too long to be only command, too short to have args
ss.logWarn("Mangled command: %q", line)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
ss.logWarn("Mangled command: %q", line)
return "", "", false
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
}
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
// " BODY=8BITMIME SIZE=1024"
// The leading space is mandatory.
func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
ss.logWarn("Failed to parse arg string: %q")
return nil, false
}
for _, m := range pm {
args[strings.ToUpper(m[1])] = m[2]
}
ss.logTrace("ESMTP params: %v", args)
return args, true
}
func (ss *Session) reset() {
ss.enterState(READY)
ss.from = ""
ss.recipients = nil
}
func (ss *Session) ooSeq(cmd string) {
ss.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
ss.logWarn("Wasn't expecting %v here", cmd)
}
// Session specific logging methods
func (ss *Session) logTrace(msg string, args ...interface{}) {
log.Tracef("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logInfo(msg string, args ...interface{}) {
log.Infof("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logWarn(msg string, args ...interface{}) {
// Update metrics
expWarnsTotal.Add(1)
log.Warnf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logError(msg string, args ...interface{}) {
// Update metrics
expErrorsTotal.Add(1)
log.Errorf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
+409
View File
@@ -0,0 +1,409 @@
package smtp
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/storage"
)
type scriptStep struct {
send string
expect int
}
// Test commands in GREET state
func TestGreetState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled HELOs
script := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
{"HELL", 500},
{"hello", 500},
{"Outlook", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Valid HELOs
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in READY state
func TestReadyState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script := []scriptStep{
{"HELO localhost", 250},
{"FOOB", 500},
{"HELO", 503},
{"DATA", 503},
{"MAIL", 501},
{"MAIL FROM john@gmail.com", 501},
{"MAIL FROM:john@gmail.com", 501},
{"MAIL FROM:<john@gmail.com> SIZE=147KB", 501},
{"MAIL FROM: <john@gmail.com> SIZE147", 501},
{"MAIL FROM:<first@last@gmail.com>", 501},
{"MAIL FROM:<first last@gmail.com>", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out some valid MAIL commands
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM: <john@gmail.com> BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\@internal@external.com>", 250},
{"RSET", 250},
{"MAIL FROM:<user\\>name@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user>name\"@host.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"user@internal\"@external.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in MAIL state
func TestMailState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, 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)
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"FOOB", 500},
{"HELO", 503},
{"DATA", 503},
{"MAIL", 503},
{"RCPT", 501},
{"RCPT TO", 501},
{"RCPT TO james@gmail.com", 501},
{"RCPT TO:<first last@host.com>", 501},
{"RCPT TO:<fred@fish@host.com", 501},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out some good RCPT commands
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"RCPT TO: <u2@gmail.com>", 250},
{"RCPT TO:u3@gmail.com", 250},
{"RCPT TO: u4@gmail.com", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user\\@internal@external.com", 250},
{"RCPT TO:<\"first last\"@host.com", 250},
{"RCPT TO:<user\\>name@host.com>", 250},
{"RCPT TO:<\"user>name\"@host.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test out recipient limit
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"RCPT TO:<u2@gmail.com>", 250},
{"RCPT TO:<u3@gmail.com>", 250},
{"RCPT TO:<u4@gmail.com>", 250},
{"RCPT TO:<u5@gmail.com>", 250},
{"RCPT TO:<u6@gmail.com>", 552},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test DATA
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
{".", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test RSET
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test QUIT
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"QUIT", 221},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in DATA state
func TestDataState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, 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)
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
var script []scriptStep
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
// Get us into DATA state
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
}
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
// Send a message
body := `To: u1@gmail.com
From: john@gmail.com
Subject: test
Hi!
`
dw := c.DotWriter()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// playSession creates a new session, reads the greeting and then plays the script
func playSession(t *testing.T, server *Server, script []scriptStep) error {
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
return fmt.Errorf("Expected a 220 greeting, got %v", code)
}
err := playScriptAgainst(t, c, script)
// Not all tests leave the session in a clean state, so the following two
// calls can fail
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
return err
}
// playScriptAgainst an existing connection, does not handle server greeting
func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) error {
for i, step := range script {
id, err := c.Cmd(step.send)
if err != nil {
return fmt.Errorf("Step %d, failed to send %q: %v", i, step.send, err)
}
c.StartResponse(id)
code, msg, err := c.ReadResponse(step.expect)
if err != nil {
err = fmt.Errorf("Step %d, sent %q, expected %v, got %v: %q",
i, step.send, step.expect, code, msg)
}
c.EndResponse(id)
if err != nil {
// Return after c.EndResponse so we don't hang the connection
return err
}
}
return nil
}
// net.Pipe does not implement deadlines
type mockConn struct {
net.Conn
}
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
// Test Server Config
cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1),
IP4port: 2500,
Domain: "inbucket.local",
DomainNoStore: "bitbucket.local",
MaxRecipients: 5,
MaxIdleSeconds: 5,
MaxMessageBytes: 5000,
StoreMessages: true,
}
// Capture log output
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it
shutdownChan := make(chan bool)
ctx, cancel := context.WithCancel(context.Background())
teardown = func() {
close(shutdownChan)
cancel()
}
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
return s, buf, teardown
}
var sessionNum int
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate
serverConn, clientConn := net.Pipe()
// Start the session
server.waitgroup.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
return clientConn
}
+199
View File
@@ -0,0 +1,199 @@
package smtp
import (
"container/list"
"context"
"expvar"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/storage"
)
func init() {
m := expvar.NewMap("smtp")
m.Set("ConnectsTotal", expConnectsTotal)
m.Set("ConnectsHist", expConnectsHist)
m.Set("ConnectsCurrent", expConnectsCurrent)
m.Set("ReceivedTotal", expReceivedTotal)
m.Set("ReceivedHist", expReceivedHist)
m.Set("ErrorsTotal", expErrorsTotal)
m.Set("ErrorsHist", expErrorsHist)
m.Set("WarnsTotal", expWarnsTotal)
m.Set("WarnsHist", expWarnsHist)
log.AddTickerFunc(func() {
expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal))
expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal))
expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal))
expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal))
})
}
// Server holds the configuration and state of our SMTP server
type Server struct {
// Configuration
host string
domain string
domainNoStore string
maxRecips int
maxIdleSeconds int
maxMessageBytes int
storeMessages bool
// Dependencies
dataStore datastore.DataStore // Mailbox/message store
globalShutdown chan bool // Shuts down Inbucket
msgHub *msghub.Hub // Pub/sub for message info
retentionScanner *datastore.RetentionScanner // Deletes expired messages
// State
listener net.Listener // Incoming network connections
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
}
var (
// Raw stat collectors
expConnectsTotal = new(expvar.Int)
expConnectsCurrent = new(expvar.Int)
expReceivedTotal = new(expvar.Int)
expErrorsTotal = new(expvar.Int)
expWarnsTotal = new(expvar.Int)
// History of certain stats
deliveredHist = list.New()
connectsHist = list.New()
errorsHist = list.New()
warnsHist = list.New()
// History rendered as comma delim string
expReceivedHist = new(expvar.String)
expConnectsHist = new(expvar.String)
expErrorsHist = new(expvar.String)
expWarnsHist = new(expvar.String)
)
// NewServer creates a new Server instance with the specificed config
func NewServer(
cfg config.SMTPConfig,
globalShutdown chan bool,
ds datastore.DataStore,
msgHub *msghub.Hub) *Server {
return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds,
maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages,
globalShutdown: globalShutdown,
dataStore: ds,
msgHub: msgHub,
retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown),
waitgroup: new(sync.WaitGroup),
}
}
// Start the listener and handle incoming connections
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.Errorf("Failed to build tcp4 address: %v", err)
s.emergencyShutdown()
return
}
log.Infof("SMTP listening on TCP4 %v", addr)
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.Errorf("SMTP failed to start tcp4 listener: %v", err)
s.emergencyShutdown()
return
}
if !s.storeMessages {
log.Infof("Load test mode active, messages will not be stored")
} else if s.domainNoStore != "" {
log.Infof("Messages sent to domain '%v' will be discarded", s.domainNoStore)
}
// Start retention scanner
s.retentionScanner.Start()
// Listener go routine
go s.serve(ctx)
// Wait for shutdown
<-ctx.Done()
log.Tracef("SMTP shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit
if err := s.listener.Close(); err != nil {
log.Errorf("Failed to close SMTP listener: %v", err)
}
}
// serve is the listen/accept loop
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections
var tempDelay time.Duration
for sessionID := 1; ; sessionID++ {
if conn, err := s.listener.Accept(); err != nil {
// There was an error accepting the connection
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.Errorf("SMTP accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
} else {
// Permanent error
select {
case <-ctx.Done():
// SMTP is shutting down
return
default:
// Something went wrong
s.emergencyShutdown()
return
}
}
} else {
tempDelay = 0
expConnectsTotal.Add(1)
s.waitgroup.Add(1)
go s.startSession(sessionID, conn)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close
s.waitgroup.Wait()
log.Tracef("SMTP connections have drained")
s.retentionScanner.Join()
}
+68
View File
@@ -0,0 +1,68 @@
package web
import (
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Context is passed into every request handler function
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore datastore.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool
}
// Close the Context (currently does nothing)
func (c *Context) Close() {
// Do nothing
}
// headerMatch returns true if the request header specified by name contains
// the specified value. Case is ignored.
func headerMatch(req *http.Request, name string, value string) bool {
name = http.CanonicalHeaderKey(name)
value = strings.ToLower(value)
if header := req.Header[name]; header != nil {
for _, hv := range header {
if value == strings.ToLower(hv) {
return true
}
}
}
return false
}
// NewContext returns a Context for the given HTTP Request
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
if err != nil {
if sess == nil {
// No session, must fail
return nil, err
}
// The session cookie was probably signed by an old key, ignore it
// gorilla created an empty session for us
err = nil
}
ctx := &Context{
Vars: vars,
Session: sess,
DataStore: DataStore,
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"textToHtml": TextToHTML,
}
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
// otherwise Mon Jan 2, 2006
func FriendlyTime(t time.Time) template.HTML {
ty, tm, td := t.Date()
ny, nm, nd := time.Now().Date()
if (ty == ny) && (tm == nm) && (td == nd) {
return template.HTML(t.Format("03:04:05 PM"))
}
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// Reverse routing function (shared with templates)
func Reverse(name string, things ...interface{}) string {
// Convert the things to strings
strs := make([]string, len(things))
for i, th := range things {
strs[i] = fmt.Sprint(th)
}
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.Errorf("Failed to reverse route: %v", err)
return "/ROUTE-ERROR"
}
return u.Path
}
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func TextToHTML(text string) template.HTML {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text))
}
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.Replace(url, "&amp;", "&", -1)
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}
+30
View File
@@ -0,0 +1,30 @@
package web
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;"))
// Check for linebreaks
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
TextToHTML("http://google.com/"),
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
assert.Equal(t,
TextToHTML("http://a.com/?q=a&n=v"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
}
+15
View File
@@ -0,0 +1,15 @@
package web
import (
"encoding/json"
"net/http"
)
// RenderJSON sets the correct HTTP headers for JSON, then writes the specified
// data (typically a struct) encoded in JSON
func RenderJSON(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Expires", "-1")
enc := json.NewEncoder(w)
return enc.Encode(data)
}
+159
View File
@@ -0,0 +1,159 @@
// Package web provides the plumbing for Inbucket's web GUI and RESTful API
package web
import (
"context"
"expvar"
"fmt"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Handler is a function type that handles an HTTP request in Inbucket
type Handler func(http.ResponseWriter, *http.Request, *Context) error
var (
// DataStore is where all the mailboxes and messages live
DataStore datastore.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
// incoming requests to the correct handler function
Router = mux.NewRouter()
webConfig config.WebConfig
server *http.Server
listener net.Listener
sessionStore sessions.Store
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
func Initialize(
cfg config.WebConfig,
shutdownChan chan bool,
ds datastore.DataStore,
mh *msghub.Hub) {
webConfig = cfg
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
DataStore = ds
msgHub = mh
// Content Paths
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(cfg.PublicDir))))
http.Handle("/", Router)
// Session cookie setup
if cfg.CookieAuthKey == "" {
log.Infof("HTTP generating random cookie.auth.key")
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
} else {
log.Tracef("HTTP using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
}
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
server = &http.Server{
Addr: addr,
Handler: nil,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
// We don't use ListenAndServe because it lacks a way to close the listener
log.Infof("HTTP listening on TCP4 %v", addr)
var err error
listener, err = net.Listen("tcp", addr)
if err != nil {
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
emergencyShutdown()
return
}
// Listener go routine
go serve(ctx)
// Wait for shutdown
select {
case _ = <-ctx.Done():
log.Tracef("HTTP server shutting down on request")
}
// Closing the listener will cause the serve() go routine to exit
if err := listener.Close(); err != nil {
log.Errorf("Failed to close HTTP listener: %v", err)
}
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case _ = <-ctx.Done():
// Nop
default:
log.Errorf("HTTP server failed: %v", err)
emergencyShutdown()
return
}
}
// ServeHTTP builds the context and passes onto the real handler
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context
ctx, err := NewContext(req)
if err != nil {
log.Errorf("HTTP failed to create context: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
err = h(w, req, ctx)
if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
}
+83
View File
@@ -0,0 +1,83 @@
package web
import (
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
"sync"
"github.com/jhillyerd/inbucket/pkg/log"
)
var cachedMutex sync.Mutex
var cachedTemplates = map[string]*template.Template{}
var cachedPartials = map[string]*template.Template{}
// RenderTemplate fetches the named template and renders it to the provided
// ResponseWriter.
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, false)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// RenderPartial fetches the named template and renders it to the provided
// ResponseWriter.
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, true)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// ParseTemplate loads the requested template along with _base.html, caching
// the result (if configured to do so)
func ParseTemplate(name string, partial bool) (*template.Template, error) {
cachedMutex.Lock()
defer cachedMutex.Unlock()
if t, ok := cachedTemplates[name]; ok {
return t, nil
}
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
log.Tracef("Parsing template %v", tempFile)
var err error
var t *template.Template
if partial {
// Need to get basename of file to make it root template w/ funcs
base := path.Base(name)
t = template.New(base).Funcs(TemplateFuncs)
t, err = t.ParseFiles(tempFile)
} else {
t = template.New("_base.html").Funcs(TemplateFuncs)
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if webConfig.TemplateCache {
if partial {
log.Tracef("Caching partial %v", name)
cachedTemplates[name] = t
} else {
log.Tracef("Caching template %v", name)
cachedTemplates[name] = t
}
}
return t, nil
}
+56
View File
@@ -0,0 +1,56 @@
// Package datastore contains implementation independent datastore logic
package datastore
import (
"errors"
"io"
"net/mail"
"sync"
"time"
"github.com/jhillyerd/enmime"
)
var (
// ErrNotExist indicates the requested message does not exist
ErrNotExist = errors.New("Message does not exist")
// ErrNotWritable indicates the message is closed; no longer writable
ErrNotWritable = errors.New("Message not writable")
)
// DataStore is an interface to get Mailboxes stored in Inbucket
type DataStore interface {
MailboxFor(emailAddress string) (Mailbox, error)
AllMailboxes() ([]Mailbox, error)
// LockFor is a temporary hack to fix #77 until Datastore revamp
LockFor(emailAddress string) (*sync.RWMutex, error)
}
// Mailbox is an interface to get and manipulate messages in a DataStore
type Mailbox interface {
GetMessages() ([]Message, error)
GetMessage(id string) (Message, error)
Purge() error
NewMessage() (Message, error)
Name() string
String() string
}
// Message is an interface for a single message in a Mailbox
type Message interface {
ID() string
From() string
To() []string
Date() time.Time
Subject() string
RawReader() (reader io.ReadCloser, err error)
ReadHeader() (msg *mail.Message, err error)
ReadBody() (body *enmime.Envelope, err error)
ReadRaw() (raw *string, err error)
Append(data []byte) error
Close() error
Delete() error
String() string
Size() int64
}
+270
View File
@@ -0,0 +1,270 @@
package filestore
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"net/mail"
"os"
"path/filepath"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/log"
)
// FileMessage implements Message and contains a little bit of data about a
// particular email message, and methods to retrieve the rest of it from disk.
type FileMessage struct {
mailbox *FileMailbox
// Stored in GOB
Fid string
Fdate time.Time
Ffrom string
Fto []string
Fsubject string
Fsize int64
// These are for creating new messages only
writable bool
writerFile *os.File
writer *bufio.Writer
}
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
// It will also delete messages over messageCap if configured.
func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
// Load index
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
// Delete old messages over messageCap
if mb.store.messageCap > 0 {
for len(mb.messages) >= mb.store.messageCap {
log.Infof("Mailbox %q over configured message cap", mb.name)
if err := mb.messages[0].Delete(); err != nil {
log.Errorf("Error deleting message: %s", err)
}
}
}
date := time.Now()
id := generateID(date)
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
}
// ID gets the ID of the Message
func (m *FileMessage) ID() string {
return m.Fid
}
// Date returns the date/time this Message was received by Inbucket
func (m *FileMessage) Date() time.Time {
return m.Fdate
}
// From returns the value of the Message From header
func (m *FileMessage) From() string {
return m.Ffrom
}
// To returns the value of the Message To header
func (m *FileMessage) To() []string {
return m.Fto
}
// Subject returns the value of the Message Subject header
func (m *FileMessage) Subject() string {
return m.Fsubject
}
// String returns a string in the form: "Subject()" from From()
func (m *FileMessage) String() string {
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
}
// Size returns the size of the Message on disk in bytes
func (m *FileMessage) Size() int64 {
return m.Fsize
}
func (m *FileMessage) rawPath() string {
return filepath.Join(m.mailbox.path, m.Fid+".raw")
}
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
return mail.ReadMessage(reader)
}
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
func (m *FileMessage) ReadBody() (body *enmime.Envelope, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
mime, err := enmime.ReadEnvelope(reader)
if err != nil {
return nil, err
}
return mime, nil
}
// RawReader opens the .raw portion of a Message as an io.ReadCloser
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
return file, nil
}
// ReadRaw opens the .raw portion of a Message and returns it as a string
func (m *FileMessage) ReadRaw() (raw *string, err error) {
reader, err := m.RawReader()
if err != nil {
return nil, err
}
defer func() {
if err := reader.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
if err != nil {
return nil, err
}
bodyString := string(bodyBytes)
return &bodyString, nil
}
// Append data to a newly opened Message, this will fail on a pre-existing Message and
// after Close() is called.
func (m *FileMessage) Append(data []byte) error {
// Prevent Appending to a pre-existing Message
if !m.writable {
return datastore.ErrNotWritable
}
// Open file for writing if we haven't yet
if m.writer == nil {
// Ensure mailbox directory exists
if err := m.mailbox.createDir(); err != nil {
return err
}
file, err := os.Create(m.rawPath())
if err != nil {
// Set writable false just in case something calls me a million times
m.writable = false
return err
}
m.writerFile = file
m.writer = bufio.NewWriter(file)
}
_, err := m.writer.Write(data)
m.Fsize += int64(len(data))
return err
}
// Close this Message for writing - no more data may be Appended. Close() will also
// trigger the creation of the .gob file.
func (m *FileMessage) Close() error {
// nil out the writer fields so they can't be used
writer := m.writer
writerFile := m.writerFile
m.writer = nil
m.writerFile = nil
if writer != nil {
if err := writer.Flush(); err != nil {
return err
}
}
if writerFile != nil {
if err := writerFile.Close(); err != nil {
return err
}
}
// Fetch headers
body, err := m.ReadBody()
if err != nil {
return err
}
// Only public fields are stored in gob, hence starting with capital F
// Parse From address
if address, err := mail.ParseAddress(body.GetHeader("From")); err == nil {
m.Ffrom = address.String()
} else {
m.Ffrom = body.GetHeader("From")
}
m.Fsubject = body.GetHeader("Subject")
// Turn the To header into a slice
if addresses, err := body.AddressList("To"); err == nil {
for _, a := range addresses {
m.Fto = append(m.Fto, a.String())
}
} else {
m.Fto = []string{body.GetHeader("To")}
}
// Refresh the index before adding our message
err = m.mailbox.readIndex()
if err != nil {
return err
}
// Made it this far without errors, add it to the index
m.mailbox.messages = append(m.mailbox.messages, m)
return m.mailbox.writeIndex()
}
// Delete this Message from disk by removing it from the index and deleting the
// raw files.
func (m *FileMessage) Delete() error {
messages := m.mailbox.messages
for i, mm := range messages {
if m == mm {
// Slice around message we are deleting
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
break
}
}
if err := m.mailbox.writeIndex(); err != nil {
return err
}
if len(m.mailbox.messages) == 0 {
// This was the last message, thus writeIndex() has removed the entire
// directory; we don't need to delete the raw file.
return nil
}
// There are still messages in the index
log.Tracef("Deleting %v", m.rawPath())
return os.Remove(m.rawPath())
}
+367
View File
@@ -0,0 +1,367 @@
package filestore
import (
"bufio"
"encoding/gob"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/log"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// Name of index file in each mailbox
const indexFileName = "index.gob"
var (
// indexMx is locked while reading/writing an index file
//
// NOTE: This is a bottleneck because it's a single lock even if we have a
// million index files
indexMx = new(sync.RWMutex)
// dirMx is locked while creating/removing directories
dirMx = new(sync.Mutex)
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
// because we only want one regardless of the number of DataStore objects
countChannel = make(chan int, 10)
)
func init() {
// Start generator
go countGenerator(countChannel)
}
// Populates the channel with numbers
func countGenerator(c chan int) {
for i := 0; true; i = (i + 1) % 10000 {
c <- i
}
}
// FileDataStore implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
type FileDataStore struct {
hashLock datastore.HashLock
path string
mailPath string
messageCap int
}
// NewFileDataStore creates a new DataStore object using the specified path
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
path := cfg.Path
if path == "" {
log.Errorf("No value configured for datastore path")
return nil
}
mailPath := filepath.Join(path, "mail")
if _, err := os.Stat(mailPath); err != nil {
// Mail datastore does not yet exist
if err = os.MkdirAll(mailPath, 0770); err != nil {
log.Errorf("Error creating dir %q: %v", mailPath, err)
}
}
return &FileDataStore{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}
}
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
// construct it's path.
func DefaultFileDataStore() datastore.DataStore {
cfg := config.GetDataStoreConfig()
return NewFileDataStore(cfg)
}
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
// does not exist, it will attempt to create it.
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
dir := stringutil.HashMailboxName(name)
s1 := dir[0:3]
s2 := dir[0:6]
path := filepath.Join(ds.mailPath, s1, s2, dir)
indexPath := filepath.Join(path, indexFileName)
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
indexPath: indexPath}, nil
}
// AllMailboxes returns a slice with all Mailboxes
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
mailboxes := make([]datastore.Mailbox, 0, 100)
infos1, err := ioutil.ReadDir(ds.mailPath)
if err != nil {
return nil, err
}
// Loop over level 1 directories
for _, inf1 := range infos1 {
if inf1.IsDir() {
l1 := inf1.Name()
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
if err != nil {
return nil, err
}
// Loop over level 2 directories
for _, inf2 := range infos2 {
if inf2.IsDir() {
l2 := inf2.Name()
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
if err != nil {
return nil, err
}
// Loop over mailboxes
for _, inf3 := range infos3 {
if inf3.IsDir() {
mbdir := inf3.Name()
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
idx := filepath.Join(mbpath, indexFileName)
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
indexPath: idx}
mailboxes = append(mailboxes, mb)
}
}
}
}
}
}
return mailboxes, nil
}
func (ds *FileDataStore) LockFor(emailAddress string) (*sync.RWMutex, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
hash := stringutil.HashMailboxName(name)
return ds.hashLock.Get(hash), nil
}
// FileMailbox implements Mailbox, manages the mail for a specific user and
// correlates to a particular directory on disk.
type FileMailbox struct {
store *FileDataStore
name string
dirName string
path string
indexLoaded bool
indexPath string
messages []*FileMessage
}
// Name of the mailbox
func (mb *FileMailbox) Name() string {
return mb.name
}
// String renders the name and directory path of the mailbox
func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]"
}
// GetMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects.
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
messages := make([]datastore.Message, len(mb.messages))
for i, m := range mb.messages {
messages[i] = m
}
return messages, nil
}
// GetMessage decodes a single message by Id and returns a Message object
func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
if id == "latest" && len(mb.messages) != 0 {
return mb.messages[len(mb.messages)-1], nil
}
for _, m := range mb.messages {
if m.Fid == id {
return m, nil
}
}
return nil, datastore.ErrNotExist
}
// Purge deletes all messages in this mailbox
func (mb *FileMailbox) Purge() error {
mb.messages = mb.messages[:0]
return mb.writeIndex()
}
// readIndex loads the mailbox index data from disk
func (mb *FileMailbox) readIndex() error {
// Clear message slice, open index
mb.messages = mb.messages[:0]
// Lock for reading
indexMx.RLock()
defer indexMx.RUnlock()
// Check if index exists
if _, err := os.Stat(mb.indexPath); err != nil {
// Does not exist, but that's not an error in our world
log.Tracef("Index %v does not exist (yet)", mb.indexPath)
mb.indexLoaded = true
return nil
}
file, err := os.Open(mb.indexPath)
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
}
}()
// Decode gob data
dec := gob.NewDecoder(bufio.NewReader(file))
for {
msg := new(FileMessage)
if err = dec.Decode(msg); err != nil {
if err == io.EOF {
// It's OK to get an EOF here
break
}
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
}
msg.mailbox = mb
mb.messages = append(mb.messages, msg)
}
mb.indexLoaded = true
return nil
}
// writeIndex overwrites the index on disk with the current mailbox data
func (mb *FileMailbox) writeIndex() error {
// Lock for writing
indexMx.Lock()
defer indexMx.Unlock()
if len(mb.messages) > 0 {
// Ensure mailbox directory exists
if err := mb.createDir(); err != nil {
return err
}
// Open index for writing
file, err := os.Create(mb.indexPath)
if err != nil {
return err
}
writer := bufio.NewWriter(file)
// Write each message and then flush
enc := gob.NewEncoder(writer)
for _, m := range mb.messages {
err = enc.Encode(m)
if err != nil {
_ = file.Close()
return err
}
}
if err := writer.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
return err
}
} else {
// No messages, delete index+maildir
log.Tracef("Removing mailbox %v", mb.path)
return mb.removeDir()
}
return nil
}
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *FileMailbox) createDir() error {
dirMx.Lock()
defer dirMx.Unlock()
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.Errorf("Failed to create directory %v, %v", mb.path, err)
return err
}
}
return nil
}
// removeDir removes the mailbox, plus empty higher level directories
func (mb *FileMailbox) removeDir() error {
dirMx.Lock()
defer dirMx.Unlock()
// remove mailbox dir, including index file
if err := os.RemoveAll(mb.path); err != nil {
return err
}
// remove parents if empty
dir := filepath.Dir(mb.path)
if removeDirIfEmpty(dir) {
removeDirIfEmpty(filepath.Dir(dir))
}
return nil
}
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
// Caller should hold dirMx. Returns true if dir was removed.
func removeDirIfEmpty(path string) (removed bool) {
f, err := os.Open(path)
if err != nil {
return false
}
files, err := f.Readdirnames(0)
_ = f.Close()
if err != nil {
return false
}
if len(files) > 0 {
// Dir not empty
return false
}
log.Tracef("Removing dir %v", path)
err = os.Remove(path)
if err != nil {
log.Errorf("Failed to remove %q: %v", path, err)
return false
}
return true
}
// generatePrefix converts a Time object into the ISO style format we use
// as a prefix for message files. Note: It is used directly by unit
// tests.
func generatePrefix(date time.Time) string {
return date.Format("20060102T150405")
}
// generateId adds a 4-digit unique number onto the end of the string
// returned by generatePrefix()
func generateID(date time.Time) string {
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
}
+583
View File
@@ -0,0 +1,583 @@
package filestore
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/stretchr/testify/assert"
)
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
root := ds.path
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Check filestore root exists
assert.True(t, isDir(root), "Expected %q to be a directory", root)
// Check mail dir exists
expect := filepath.Join(root, "mail")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check first hash section does not exist
expect = filepath.Join(root, "mail", "474")
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba6")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check files
mbPath := expect
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id1+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id2+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err := mb.GetMessage(id1)
assert.Nil(t, err)
err = msg.Delete()
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
msg, err = mb.GetMessage(id2)
assert.Nil(t, err)
err = msg.Delete()
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
// No messages, index & maildir should be removed
expect = filepath.Join(mbPath, "index.gob")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = mbPath
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test FileDataStore.AllMailboxes()
func TestFSAllMailboxes(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
// Create day old message
date := time.Now().Add(-24 * time.Hour)
deliverMessage(ds, name, "Old Message", date)
// Create current message
date = time.Now()
deliverMessage(ds, name, "New Message", date)
}
mboxes, err := ds.AllMailboxes()
assert.Nil(t, err)
assert.Equal(t, len(mboxes), 5)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test delivering several messages to the same mailbox, meanwhile querying its
// contents with a new mailbox object each time
func TestFSDeliverMany(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects {
// Check number of messages
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
assert.Equal(t, i, len(msgs), "Expected %v message(s), but got %v", i, len(msgs))
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Confirm delivery order
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test deleting messages
func TestFSDelete(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Delete a couple messages
_ = msgs[1].Delete()
_ = msgs[3].Delete()
// Confirm deletion
mb, err = ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err = mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
subjects = []string{"alpha", "charlie", "echo"}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
// Try appending one more
deliverMessage(ds, mbName, "foxtrot", time.Now())
mb, err = ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err = mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test purging a mailbox
func TestFSPurge(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Purge mailbox
err = mb.Purge()
assert.Nil(t, err)
// Confirm deletion
mb, err = ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err = mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
assert.Equal(t, len(msgs), 0, "Expected mailbox to have zero messages, got %v", len(msgs))
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test message size calculation
func TestFSSize(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects))
sentSizes := make([]int64, len(subjects))
for i, subj := range subjects {
// Add a message
id, size := deliverMessage(ds, mbName, subj, time.Now())
sentIds[i] = id
sentSizes[i] = size
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
for i, id := range sentIds {
msg, err := mb.GetMessage(id)
assert.Nil(t, err)
expect := sentSizes[i]
size := msg.Size()
assert.Equal(t, expect, size, "Expected size of %v, got %v", expect, size)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"a", "b", "c"}
sentIds := make([]string, len(subjects))
for i, subj := range subjects {
// Add a message
id, _ := deliverMessage(ds, mbName, subj, time.Now())
sentIds[i] = id
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
// Delete a message file without removing it from index
msg, err := mb.GetMessage(sentIds[1])
assert.Nil(t, err)
fmsg := msg.(*FileMessage)
_ = os.Remove(fmsg.rawPath())
msg, err = mb.GetMessage(sentIds[1])
assert.Nil(t, err)
// Try to read parts of message
_, err = msg.ReadHeader()
assert.Error(t, err)
_, err = msg.ReadBody()
assert.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test delivering several messages to the same mailbox, see if message cap works
func TestFSMessageCap(t *testing.T) {
mbCap := 10
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
defer teardownDataStore(ds)
mbName := "captain"
for i := 0; i < 20; i++ {
// Add a message
subj := fmt.Sprintf("subject %v", i)
deliverMessage(ds, mbName, subj, time.Now())
t.Logf("Delivered %q", subj)
// Check number of messages
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
if len(msgs) > mbCap {
t.Errorf("Mailbox should be capped at %v messages, but has %v", mbCap, len(msgs))
}
// Check that the first message is correct
first := i - mbCap + 1
if first < 0 {
first = 0
}
firstSubj := fmt.Sprintf("subject %v", first)
if firstSubj != msgs[0].Subject() {
t.Errorf("Expected first subject to be %q, got %q", firstSubj, msgs[0].Subject())
}
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test delivering several messages to the same mailbox, see if no message cap works
func TestFSNoMessageCap(t *testing.T) {
mbCap := 0
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
defer teardownDataStore(ds)
mbName := "captain"
for i := 0; i < 20; i++ {
// Add a message
subj := fmt.Sprintf("subject %v", i)
deliverMessage(ds, mbName, subj, time.Now())
t.Logf("Delivered %q", subj)
// Check number of messages
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
if len(msgs) != i+1 {
t.Errorf("Expected %v messages, got %v", i+1, len(msgs))
}
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test Get the latest message
func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Test empty mailbox
mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err := mb.GetMessage("latest")
assert.Nil(t, msg)
assert.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Test get the latest message
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = mb.GetMessage("wrongid")
assert.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
if err != nil {
panic(err)
}
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
cfg.Path = path
return NewFileDataStore(cfg).(*FileDataStore), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *FileDataStore, mbName string, subject string,
date time.Time) (id string, size int64) {
// Build fake SMTP message for delivery
testMsg := make([]byte, 0, 300)
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
testMsg = append(testMsg, []byte("From: somebodyelse@host\r\n")...)
testMsg = append(testMsg, []byte(fmt.Sprintf("Subject: %s\r\n", subject))...)
testMsg = append(testMsg, []byte("\r\n")...)
testMsg = append(testMsg, []byte("Test Body\r\n")...)
mb, err := ds.MailboxFor(mbName)
if err != nil {
panic(err)
}
// Create message object
id = generateID(date)
msg, err := mb.NewMessage()
if err != nil {
panic(err)
}
fmsg := msg.(*FileMessage)
fmsg.Fdate = date
fmsg.Fid = id
if err = msg.Append(testMsg); err != nil {
panic(err)
}
if err = msg.Close(); err != nil {
panic(err)
}
return id, int64(len(testMsg))
}
func teardownDataStore(ds *FileDataStore) {
if err := os.RemoveAll(ds.path); err != nil {
panic(err)
}
}
func isPresent(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func isFile(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return !fi.IsDir()
}
return false
}
func isDir(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return fi.IsDir()
}
return false
}
+19
View File
@@ -0,0 +1,19 @@
package datastore
import (
"strconv"
"sync"
)
type HashLock [4096]sync.RWMutex
func (h *HashLock) Get(hash string) *sync.RWMutex {
if len(hash) < 3 {
return nil
}
i, err := strconv.ParseInt(hash[0:3], 16, 0)
if err != nil {
return nil
}
return &h[i]
}
+61
View File
@@ -0,0 +1,61 @@
package datastore_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/storage"
)
func TestHashLock(t *testing.T) {
hl := &datastore.HashLock{}
// Invalid hashes
testCases := []struct {
name, input string
}{
{"empty", ""},
{"short", "a0"},
{"badhex", "zzzzzzzzzzzzzzzzzzzzzzz"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
l := hl.Get(tc.input)
if l != nil {
t.Errorf("Expected nil lock for %s %q, got %v", tc.name, tc.input, l)
}
})
}
// Valid hashes
testStrings := []string{
"deadbeef",
"00000000",
"ffffffff",
}
for _, ts := range testStrings {
t.Run(ts, func(t *testing.T) {
l := hl.Get(ts)
if l == nil {
t.Errorf("Expeced non-nil lock for hex string %q", ts)
}
})
}
a := hl.Get("deadbeef")
b := hl.Get("deadbeef")
if a != b {
t.Errorf("Expected identical locks for identical hashes, got: %p != %p", a, b)
}
a = hl.Get("deadbeef")
b = hl.Get("d3adb33f")
if a == b {
t.Errorf("Expected different locks for different hashes, got: %p == %p", a, b)
}
a = hl.Get("deadbeef")
b = hl.Get("deadb33f")
if a != b {
t.Errorf("Expected identical locks for identical leading hashes, got: %p != %p", a, b)
}
}
+180
View File
@@ -0,0 +1,180 @@
package datastore
import (
"container/list"
"expvar"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/log"
)
var (
retentionScanCompleted = time.Now()
retentionScanCompletedMu sync.RWMutex
// History counters
expRetentionDeletesTotal = new(expvar.Int)
expRetentionPeriod = new(expvar.Int)
expRetainedCurrent = new(expvar.Int)
// History of certain stats
retentionDeletesHist = list.New()
retainedHist = list.New()
// History rendered as comma delimited string
expRetentionDeletesHist = new(expvar.String)
expRetainedHist = new(expvar.String)
)
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
log.AddTickerFunc(func() {
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
})
}
// RetentionScanner looks for messages older than the configured retention period and deletes them.
type RetentionScanner struct {
globalShutdown chan bool // Closes when Inbucket needs to shut down
retentionShutdown chan bool // Closed after the scanner has shut down
ds DataStore
retentionPeriod time.Duration
retentionSleep time.Duration
}
// NewRetentionScanner launches a go-routine that scans for expired
// messages, following the configured interval
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
cfg := config.GetDataStoreConfig()
rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool),
ds: ds,
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
}
// expRetentionPeriod is displayed on the status page
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
return rs
}
// Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start() {
if rs.retentionPeriod <= 0 {
log.Infof("Retention scanner disabled")
close(rs.retentionShutdown)
return
}
log.Infof("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
start := time.Now()
retentionLoop:
for {
// Prevent scanner from starting more than once a minute
since := time.Since(start)
if since < time.Minute {
dur := time.Minute - since
log.Tracef("Retention scanner sleeping for %v", dur)
select {
case <-rs.globalShutdown:
break retentionLoop
case <-time.After(dur):
}
}
// Kickoff scan
start = time.Now()
if err := rs.doScan(); err != nil {
log.Errorf("Error during retention scan: %v", err)
}
// Check for global shutdown
select {
case <-rs.globalShutdown:
break retentionLoop
default:
}
}
log.Tracef("Retention scanner shut down")
close(rs.retentionShutdown)
}
// doScan does a single pass of all mailboxes looking for messages that can be purged
func (rs *RetentionScanner) doScan() error {
log.Tracef("Starting retention scan")
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
mboxes, err := rs.ds.AllMailboxes()
if err != nil {
return err
}
retained := 0
// Loop over all mailboxes
for _, mb := range mboxes {
messages, err := mb.GetMessages()
if err != nil {
return err
}
// Loop over all messages in mailbox
for _, msg := range messages {
if msg.Date().Before(cutoff) {
log.Tracef("Purging expired message %v", msg.ID())
err = msg.Delete()
if err != nil {
// Log but don't abort
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
} else {
expRetentionDeletesTotal.Add(1)
}
} else {
retained++
}
}
// Sleep after completing a mailbox
select {
case <-rs.globalShutdown:
log.Tracef("Retention scan aborted due to shutdown")
return nil
case <-time.After(rs.retentionSleep):
// Reduce disk thrashing
}
}
// Update metrics
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
return nil
}
// Join does not retun until the retention scanner has shut down
func (rs *RetentionScanner) Join() {
if rs.retentionShutdown != nil {
<-rs.retentionShutdown
}
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}
+67
View File
@@ -0,0 +1,67 @@
package datastore
import (
"fmt"
"testing"
"time"
)
func TestDoRetentionScan(t *testing.T) {
// Create mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mb2 := &MockMailbox{}
mb3 := &MockMailbox{}
// Mockup some different aged messages (num is in hours)
new1 := mockMessage(0)
new2 := mockMessage(1)
new3 := mockMessage(2)
old1 := mockMessage(4)
old2 := mockMessage(12)
old3 := mockMessage(24)
// First it should ask for all mailboxes
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
// Then for all messages on each box
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
mb3.On("GetMessages").Return([]Message{new3}, nil)
// Test 4 hour retention
rs := &RetentionScanner{
ds: mds,
retentionPeriod: 4*time.Hour - time.Minute,
retentionSleep: 0,
}
if err := rs.doScan(); err != nil {
t.Error(err)
}
// Check our assertions
mds.AssertExpectations(t)
mb1.AssertExpectations(t)
mb2.AssertExpectations(t)
mb3.AssertExpectations(t)
// Delete should not have been called on new messages
new1.AssertNotCalled(t, "Delete")
new2.AssertNotCalled(t, "Delete")
new3.AssertNotCalled(t, "Delete")
// Delete should have been called once on old messages
old1.AssertNumberOfCalls(t, "Delete", 1)
old2.AssertNumberOfCalls(t, "Delete", 1)
old3.AssertNumberOfCalls(t, "Delete", 1)
}
// Make a MockMessage of a specific age
func mockMessage(ageHours int) *MockMessage {
msg := &MockMessage{}
msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
msg.On("Delete").Return(nil)
return msg
}
+162
View File
@@ -0,0 +1,162 @@
package datastore
import (
"io"
"net/mail"
"sync"
"time"
"github.com/jhillyerd/enmime"
"github.com/stretchr/testify/mock"
)
// MockDataStore is a shared mock for unit testing
type MockDataStore struct {
mock.Mock
}
// MailboxFor mock function
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
args := m.Called(name)
return args.Get(0).(Mailbox), args.Error(1)
}
// AllMailboxes mock function
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
args := m.Called()
return args.Get(0).([]Mailbox), args.Error(1)
}
func (m *MockDataStore) LockFor(name string) (*sync.RWMutex, error) {
return &sync.RWMutex{}, nil
}
// MockMailbox is a shared mock for unit testing
type MockMailbox struct {
mock.Mock
}
// GetMessages mock function
func (m *MockMailbox) GetMessages() ([]Message, error) {
args := m.Called()
return args.Get(0).([]Message), args.Error(1)
}
// GetMessage mock function
func (m *MockMailbox) GetMessage(id string) (Message, error) {
args := m.Called(id)
return args.Get(0).(Message), args.Error(1)
}
// Purge mock function
func (m *MockMailbox) Purge() error {
args := m.Called()
return args.Error(0)
}
// NewMessage mock function
func (m *MockMailbox) NewMessage() (Message, error) {
args := m.Called()
return args.Get(0).(Message), args.Error(1)
}
// Name mock function
func (m *MockMailbox) Name() string {
args := m.Called()
return args.String(0)
}
// String mock function
func (m *MockMailbox) String() string {
args := m.Called()
return args.String(0)
}
// MockMessage is a shared mock for unit testing
type MockMessage struct {
mock.Mock
}
// ID mock function
func (m *MockMessage) ID() string {
args := m.Called()
return args.String(0)
}
// From mock function
func (m *MockMessage) From() string {
args := m.Called()
return args.String(0)
}
// To mock function
func (m *MockMessage) To() []string {
args := m.Called()
return args.Get(0).([]string)
}
// Date mock function
func (m *MockMessage) Date() time.Time {
args := m.Called()
return args.Get(0).(time.Time)
}
// Subject mock function
func (m *MockMessage) Subject() string {
args := m.Called()
return args.String(0)
}
// ReadHeader mock function
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
args := m.Called()
return args.Get(0).(*mail.Message), args.Error(1)
}
// ReadBody mock function
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
args := m.Called()
return args.Get(0).(*enmime.Envelope), args.Error(1)
}
// ReadRaw mock function
func (m *MockMessage) ReadRaw() (raw *string, err error) {
args := m.Called()
return args.Get(0).(*string), args.Error(1)
}
// RawReader mock function
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
args := m.Called()
return args.Get(0).(io.ReadCloser), args.Error(1)
}
// Size mock function
func (m *MockMessage) Size() int64 {
args := m.Called()
return int64(args.Int(0))
}
// Append mock function
func (m *MockMessage) Append(data []byte) error {
// []byte arg seems to mess up testify/mock
return nil
}
// Close mock function
func (m *MockMessage) Close() error {
args := m.Called()
return args.Error(0)
}
// Delete mock function
func (m *MockMessage) Delete() error {
args := m.Called()
return args.Error(0)
}
// String mock function
func (m *MockMessage) String() string {
args := m.Called()
return args.String(0)
}
+226
View File
@@ -0,0 +1,226 @@
package stringutil
import (
"bytes"
"crypto/sha1"
"fmt"
"io"
"strings"
)
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
// and returns just the mailbox name (ex: "user"). Returns an error if
// localPart contains invalid characters; it won't accept any that must be
// quoted according to RFC3696.
func ParseMailboxName(localPart string) (result string, err error) {
if localPart == "" {
return "", fmt.Errorf("Mailbox name cannot be empty")
}
result = strings.ToLower(localPart)
invalid := make([]byte, 0, 10)
for i := 0; i < len(result); i++ {
c := result[i]
switch {
case 'a' <= c && c <= 'z':
case '0' <= c && c <= '9':
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
default:
invalid = append(invalid, c)
}
}
if len(invalid) > 0 {
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
}
if idx := strings.Index(result, "+"); idx > -1 {
result = result[0:idx]
}
return result, nil
}
// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as
// the directory to house the mailbox
func HashMailboxName(mailbox string) string {
h := sha1.New()
if _, err := io.WriteString(h, mailbox); err != nil {
// This shouldn't ever happen
return ""
}
return fmt.Sprintf("%x", h.Sum(nil))
}
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
func ValidateDomainPart(domain string) bool {
if len(domain) == 0 {
return false
}
if len(domain) > 255 {
return false
}
if domain[len(domain)-1] != '.' {
domain += "."
}
prev := '.'
labelLen := 0
hasAlphaNum := false
for _, c := range domain {
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') || c == '_':
// Must contain some of these to be a valid label
hasAlphaNum = true
labelLen++
case c == '-':
if prev == '.' {
// Cannot lead with hyphen
return false
}
case c == '.':
if prev == '.' || prev == '-' {
// Cannot end with hyphen or double-dot
return false
}
if labelLen > 63 {
return false
}
if !hasAlphaNum {
return false
}
labelLen = 0
hasAlphaNum = false
default:
// Unknown character
return false
}
prev = c
}
return true
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696.
func ParseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", fmt.Errorf("Empty address")
}
if len(address) > 320 {
return "", "", fmt.Errorf("Address exceeds 320 characters")
}
if address[0] == '@' {
return "", "", fmt.Errorf("Address cannot start with @ symbol")
}
if address[0] == '.' {
return "", "", fmt.Errorf("Address cannot start with a period")
}
// Loop over address parsing out local part
buf := new(bytes.Buffer)
prev := byte('.')
inCharQuote := false
inStringQuote := false
LOOP:
for i := 0; i < len(address); i++ {
c := address[i]
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
// Letters are OK
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case '0' <= c && c <= '9':
// Numbers are OK
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '.':
// A single period is OK
if prev == '.' {
// Sequence of periods is not permitted
return "", "", fmt.Errorf("Sequence of periods is not permitted")
}
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '\\':
inCharQuote = true
case c == '"':
if inCharQuote {
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else if inStringQuote {
inStringQuote = false
} else {
if i == 0 {
inStringQuote = true
} else {
return "", "", fmt.Errorf("Quoted string can only begin at start of address")
}
}
case c == '@':
if inCharQuote || inStringQuote {
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
// End of local-part
if i > 128 {
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
}
if prev == '.' {
return "", "", fmt.Errorf("Local part cannot end with a period")
}
domain = address[i+1:]
break LOOP
}
case c > 127:
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
default:
if inCharQuote || inStringQuote {
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
return "", "", fmt.Errorf("Character %q must be quoted", c)
}
}
prev = c
}
if inCharQuote {
return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair")
}
if inStringQuote {
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
}
if !ValidateDomainPart(domain) {
return "", "", fmt.Errorf("Domain part validation failed")
}
return buf.String(), domain, nil
}
+215
View File
@@ -0,0 +1,215 @@
package stringutil
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseMailboxName(t *testing.T) {
var validTable = []struct {
input string
expect string
}{
{"mailbox", "mailbox"},
{"user123", "user123"},
{"MailBOX", "mailbox"},
{"First.Last", "first.last"},
{"user+label", "user"},
{"chars!#$%", "chars!#$%"},
{"chars&'*-", "chars&'*-"},
{"chars=/?^", "chars=/?^"},
{"chars_`.{", "chars_`.{"},
{"chars|}~", "chars|}~"},
}
for _, tt := range validTable {
if result, err := ParseMailboxName(tt.input); err != nil {
t.Errorf("Error while parsing %q: %v", tt.input, err)
} else {
if result != tt.expect {
t.Errorf("Parsing %q, expected %q, got %q", tt.input, tt.expect, result)
}
}
}
var invalidTable = []struct {
input, msg string
}{
{"", "Empty mailbox name is not permitted"},
{"user@host", "@ symbol not permitted"},
{"first last", "Space not permitted"},
{"first\"last", "Double quote not permitted"},
{"first\nlast", "Control chars not permitted"},
}
for _, tt := range invalidTable {
if _, err := ParseMailboxName(tt.input); err == nil {
t.Errorf("Didn't get an error while parsing %q: %v", tt.input, tt.msg)
}
}
}
func TestHashMailboxName(t *testing.T) {
assert.Equal(t, HashMailboxName("mail"), "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e")
}
func TestValidateDomain(t *testing.T) {
assert.False(t, ValidateDomainPart(strings.Repeat("a", 256)),
"Max domain length is 255")
assert.False(t, ValidateDomainPart(strings.Repeat("a", 64)+".com"),
"Max label length is 63")
assert.True(t, ValidateDomainPart(strings.Repeat("a", 63)+".com"),
"Should allow 63 char label")
var testTable = []struct {
input string
expect bool
msg string
}{
{"", false, "Empty domain is not valid"},
{"hostname", true, "Just a hostname is valid"},
{"github.com", true, "Two labels should be just fine"},
{"my-domain.com", true, "Hyphen is allowed mid-label"},
{"_domainkey.foo.com", true, "Underscores are allowed"},
{"bar.com.", true, "Must be able to end with a dot"},
{"ABC.6DBS.com", true, "Mixed case is OK"},
{"mail.123.com", true, "Number only label valid"},
{"123.com", true, "Number only label valid"},
{"google..com", false, "Double dot not valid"},
{".foo.com", false, "Cannot start with a dot"},
{"google\r.com", false, "Special chars not allowed"},
{"foo.-bar.com", false, "Label cannot start with hyphen"},
{"foo-.bar.com", false, "Label cannot end with hyphen"},
}
for _, tt := range testTable {
if ValidateDomainPart(tt.input) != tt.expect {
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
}
}
}
func TestValidateLocal(t *testing.T) {
var testTable = []struct {
input string
expect bool
msg string
}{
{"", false, "Empty local is not valid"},
{"a", true, "Single letter should be fine"},
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
{"FirstLast", true, "Mixed case permitted"},
{"user123", true, "Numbers permitted"},
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},
{"first.last", true, "Embedded period is permitted"},
{"first..last", false, "Sequence of periods is not allowed"},
{".user", false, "Cannot lead with a period"},
{"user.", false, "Cannot end with a period"},
{"james@mail", false, "Unquoted @ not permitted"},
{"first last", false, "Unquoted space not permitted"},
{"tricky\\. ", false, "Unquoted space not permitted"},
{"no,commas", false, "Unquoted comma not allowed"},
{"t[es]t", false, "Unquoted square brackets not allowed"},
{"james\\", false, "Cannot end with backslash quote"},
{"james\\@mail", true, "Quoted @ permitted"},
{"quoted\\ space", true, "Quoted space permitted"},
{"no\\,commas", true, "Quoted comma is OK"},
{"t\\[es\\]t", true, "Quoted brackets are OK"},
{"user\\name", true, "Should be able to quote a-z"},
{"USER\\NAME", true, "Should be able to quote A-Z"},
{"user\\1", true, "Should be able to quote a digit"},
{"one\\$\\|", true, "Should be able to quote plain specials"},
{"return\\\r", true, "Should be able to quote ASCII control chars"},
{"high\\\x80", false, "Should not accept > 7-bit quoted chars"},
{"quote\\\"", true, "Quoted double quote is permitted"},
{"\"james\"", true, "Quoted a-z is permitted"},
{"\"first last\"", true, "Quoted space is permitted"},
{"\"quoted@sign\"", true, "Quoted @ is allowed"},
{"\"qp\\\"quote\"", true, "Quoted quote within quoted string is OK"},
{"\"unterminated", false, "Quoted string must be terminated"},
{"\"unterminated\\\"", false, "Quoted string must be terminated"},
{"embed\"quote\"string", false, "Embedded quoted string is illegal"},
{"user+mailbox", true, "RFC3696 test case should be valid"},
{"customer/department=shipping", true, "RFC3696 test case should be valid"},
{"$A12345", true, "RFC3696 test case should be valid"},
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
{"_somename", true, "RFC3696 test case should be valid"},
}
for _, tt := range testTable {
_, _, err := ParseEmailAddress(tt.input + "@domain.com")
if (err != nil) == tt.expect {
if err != nil {
t.Logf("Got error: %s", err)
}
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
}
}
}
func TestParseEmailAddress(t *testing.T) {
// Test some good email addresses
var testTable = []struct {
input, local, domain string
}{
{"root@localhost", "root", "localhost"},
{"FirstLast@domain.local", "FirstLast", "domain.local"},
{"route66@prodigy.net", "route66", "prodigy.net"},
{"lorbit!user@uucp", "lorbit!user", "uucp"},
{"user+spam@gmail.com", "user+spam", "gmail.com"},
{"first.last@domain.local", "first.last", "domain.local"},
{"first\\ last@_key.domain.com", "first last", "_key.domain.com"},
{"first\\\"last@a.b.c", "first\"last", "a.b.c"},
{"user\\@internal@myhost.ca", "user@internal", "myhost.ca"},
{"\"first last@evil\"@top-secret.gov", "first last@evil", "top-secret.gov"},
{"\"line\nfeed\"@linenoise.co.uk", "line\nfeed", "linenoise.co.uk"},
{"user+mailbox@host", "user+mailbox", "host"},
{"customer/department=shipping@host", "customer/department=shipping", "host"},
{"$A12345@host", "$A12345", "host"},
{"!def!xyz%abc@host", "!def!xyz%abc", "host"},
{"_somename@host", "_somename", "host"},
}
for _, tt := range testTable {
local, domain, err := ParseEmailAddress(tt.input)
if err != nil {
t.Errorf("Error when parsing %q: %s", tt.input, err)
} else {
if tt.local != local {
t.Errorf("When parsing %q, expected local %q, got %q instead",
tt.input, tt.local, local)
}
if tt.domain != domain {
t.Errorf("When parsing %q, expected domain %q, got %q instead",
tt.input, tt.domain, domain)
}
}
}
// Check that validations fail correctly
var badTable = []struct {
input, msg string
}{
{"", "Empty address not permitted"},
{"user", "Missing domain part"},
{"@host", "Missing local part"},
{"user\\@host", "Missing domain part"},
{"\"user@host\"", "Missing domain part"},
{"\"user@host", "Unterminated quoted string"},
{"first last@host", "Unquoted space"},
{"user@bad!domain", "Invalid domain"},
{".user@host", "Can't lead with a ."},
{"user.@host", "Can't end local with a dot"},
{"user@bad domain", "No spaces in domain permitted"},
}
for _, tt := range badTable {
if _, _, err := ParseEmailAddress(tt.input); err == nil {
t.Errorf("Did not get expected error when parsing %q: %s", tt.input, tt.msg)
}
}
}
+318
View File
@@ -0,0 +1,318 @@
package webui
import (
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"github.com/jhillyerd/inbucket/pkg/log"
"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"
)
// 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 = stringutil.ParseMailboxName(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,
})
}
// 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 := stringutil.ParseMailboxName(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
messages, err := mb.GetMessages()
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
log.Tracef("Got %v messsages", len(messages))
// 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
id := ctx.Vars["id"]
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
msg, err := mb.GetMessage(id)
if err == datastore.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)
}
mime, err := msg.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
body := template.HTML(web.TextToHTML(mime.Text))
htmlAvailable := mime.HTML != ""
var htmlBody template.HTML
if htmlAvailable {
if str, err := sanitize.HTML(mime.HTML); err == nil {
htmlBody = template.HTML(str)
} else {
log.Warnf("HTML sanitizer failed: %s", err)
}
}
// 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": mime.Errors,
"attachments": mime.Attachments,
})
}
// MailboxHTML displays the HTML content of a message. Renders a partial
func MailboxHTML(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
mime, err := message.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
// Render partial template
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": message,
// TODO It is not really safe to render, need to sanitize, issue #5
"body": template.HTML(mime.HTML),
})
}
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
func MailboxSource(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 := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
raw, err := message.ReadRaw()
if err != nil {
return fmt.Errorf("ReadRaw(%q) failed: %v", id, err)
}
// Output message source
w.Header().Set("Content-Type", "text/plain")
if _, err := io.WriteString(w, *raw); err != nil {
return err
}
return nil
}
// 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 := stringutil.ParseMailboxName(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
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
body, err := message.ReadBody()
if err != nil {
return err
}
if int(num) >= len(body.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
}
part := body.Attachments[num]
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
if _, err := io.Copy(w, part); err != nil {
return err
}
return nil
}
// 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
name, err := stringutil.ParseMailboxName(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
}
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")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
body, err := message.ReadBody()
if err != nil {
return err
}
if int(num) >= len(body.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
}
part := body.Attachments[num]
// Output attachment
w.Header().Set("Content-Type", part.ContentType)
if _, err := io.Copy(w, part); err != nil {
return err
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package webui
import (
"github.com/jhillyerd/inbucket/pkg/server/web"
)
const (
// maximum mailboxes to remember
maxRemembered = 8
// session value key; referenced in templates, do not change
mailboxKey = "recentMailboxes"
)
// RememberMailbox manages the list of recently accessed mailboxes stored in the session
func RememberMailbox(ctx *web.Context, mailbox string) {
recent := RecentMailboxes(ctx)
newRecent := make([]string, 1, maxRemembered)
newRecent[0] = mailbox
for _, recBox := range recent {
// Insert until newRecent is full, but don't repeat the new mailbox
if len(newRecent) < maxRemembered && mailbox != recBox {
newRecent = append(newRecent, recBox)
}
}
ctx.Session.Values[mailboxKey] = newRecent
}
// RecentMailboxes returns a slice of the most recently accessed mailboxes
func RecentMailboxes(ctx *web.Context) []string {
val := ctx.Session.Values[mailboxKey]
recent, _ := val.([]string)
return recent
}
+106
View File
@@ -0,0 +1,106 @@
package webui
import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// RootIndex serves the Inbucket landing page
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
greeting, err := ioutil.ReadFile(config.GetWebConfig().GreetingFile)
if err != nil {
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 web.RenderTemplate("root/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"greeting": template.HTML(string(greeting)),
})
}
// RootMonitor serves the Inbucket monitor page
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.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, web.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 web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
})
}
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.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, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err := stringutil.ParseMailboxName(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
}
// 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("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
})
}
// RootStatus serves the Inbucket status page
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
smtpListener := fmt.Sprintf("%s:%d", config.GetSMTPConfig().IP4address.String(),
config.GetSMTPConfig().IP4port)
pop3Listener := fmt.Sprintf("%s:%d", config.GetPOP3Config().IP4address.String(),
config.GetPOP3Config().IP4port)
webListener := fmt.Sprintf("%s:%d", config.GetWebConfig().IP4address.String(),
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 web.RenderTemplate("root/status.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"version": config.Version,
"buildDate": config.BuildDate,
"smtpListener": smtpListener,
"pop3Listener": pop3Listener,
"webListener": webListener,
"smtpConfig": config.GetSMTPConfig(),
"dataStoreConfig": config.GetDataStoreConfig(),
})
}
+35
View File
@@ -0,0 +1,35 @@
// Package webui powers Inbucket's web GUI
package webui
import (
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/server/web"
)
// 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")
r.Path("/monitor").Handler(
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
r.Path("/monitor/{name}").Handler(
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(
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
r.Path("/mailbox/{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(
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
}
+110
View File
@@ -0,0 +1,110 @@
package sanitize
import (
"bytes"
"strings"
"github.com/gorilla/css/scanner"
)
// propertyRule may someday allow control of what values are valid for a particular property.
type propertyRule struct{}
var allowedProperties = map[string]propertyRule{
"align": {},
"background-color": {},
"border": {},
"border-bottom": {},
"border-left": {},
"border-radius": {},
"border-right": {},
"border-top": {},
"box-sizing": {},
"clear": {},
"color": {},
"content": {},
"display": {},
"font-family": {},
"font-size": {},
"font-weight": {},
"height": {},
"line-height": {},
"margin": {},
"margin-bottom": {},
"margin-left": {},
"margin-right": {},
"margin-top": {},
"max-height": {},
"max-width": {},
"overflow": {},
"padding": {},
"padding-bottom": {},
"padding-left": {},
"padding-right": {},
"padding-top": {},
"table-layout": {},
"text-align": {},
"text-decoration": {},
"text-shadow": {},
"vertical-align": {},
"width": {},
"word-break": {},
}
// Handler Token, return next state.
type stateHandler func(b *bytes.Buffer, t *scanner.Token) stateHandler
func sanitizeStyle(input string) string {
b := &bytes.Buffer{}
scan := scanner.New(input)
state := stateStart
for {
t := scan.Next()
if t.Type == scanner.TokenEOF {
return b.String()
}
if t.Type == scanner.TokenError {
return ""
}
state = state(b, t)
if state == nil {
return ""
}
}
}
func stateStart(b *bytes.Buffer, t *scanner.Token) stateHandler {
switch t.Type {
case scanner.TokenIdent:
_, ok := allowedProperties[strings.ToLower(t.Value)]
if !ok {
return stateEat
}
b.WriteString(t.Value)
return stateValid
case scanner.TokenS:
return stateStart
}
// Unexpected type.
b.WriteString("/*" + t.Type.String() + "*/")
return stateEat
}
func stateEat(b *bytes.Buffer, t *scanner.Token) stateHandler {
if t.Type == scanner.TokenChar && t.Value == ";" {
// Done eating.
return stateStart
}
// Throw away this token.
return stateEat
}
func stateValid(b *bytes.Buffer, t *scanner.Token) stateHandler {
state := stateValid
if t.Type == scanner.TokenChar && t.Value == ";" {
// End of property.
state = stateStart
}
b.WriteString(t.Value)
return state
}
+34
View File
@@ -0,0 +1,34 @@
package sanitize
import (
"testing"
)
func TestSanitizeStyle(t *testing.T) {
testCases := []struct {
input, want string
}{
{"", ""},
{
"color: red;",
"color: red;",
},
{
"background-color: black; color: white",
"background-color: black;color: white",
},
{
"background-color: black; invalid: true; color: white",
"background-color: black;color: white",
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got := sanitizeStyle(tc.input)
if got != tc.want {
t.Errorf("got: %q, want: %q, input: %q", got, tc.want, tc.input)
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package sanitize
import (
"bufio"
"bytes"
"io"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/net/html"
)
var (
cssSafe = regexp.MustCompile(".*")
policy = bluemonday.UGCPolicy().
AllowElements("center").
AllowAttrs("style").Matching(cssSafe).Globally()
)
func HTML(html string) (output string, err error) {
output, err = sanitizeStyleTags(html)
if err != nil {
return "", err
}
output = policy.Sanitize(output)
return
}
func sanitizeStyleTags(input string) (string, error) {
r := strings.NewReader(input)
b := &bytes.Buffer{}
if err := styleTagFilter(b, r); err != nil {
return "", err
}
return b.String(), nil
}
func styleTagFilter(w io.Writer, r io.Reader) error {
bw := bufio.NewWriter(w)
b := make([]byte, 256)
z := html.NewTokenizer(r)
for {
b = b[:0]
tt := z.Next()
switch tt {
case html.ErrorToken:
err := z.Err()
if err == io.EOF {
return bw.Flush()
}
return err
case html.StartTagToken, html.SelfClosingTagToken:
name, hasAttr := z.TagName()
if !hasAttr {
bw.Write(z.Raw())
continue
}
b = append(b, '<')
b = append(b, name...)
for {
key, val, more := z.TagAttr()
strval := string(val)
style := false
if strings.ToLower(string(key)) == "style" {
style = true
strval = sanitizeStyle(strval)
}
if !style || strval != "" {
b = append(b, ' ')
b = append(b, key...)
b = append(b, '=', '"')
b = append(b, []byte(html.EscapeString(strval))...)
b = append(b, '"')
}
if !more {
break
}
}
if tt == html.SelfClosingTagToken {
b = append(b, '/')
}
bw.Write(append(b, '>'))
default:
bw.Write(z.Raw())
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package sanitize_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
)
// TestHTMLPlainStrings test plain text passthrough
func TestHTMLPlainStrings(t *testing.T) {
testStrings := []string{
"",
"plain string",
"one &lt; two",
}
for _, ts := range testStrings {
t.Run(ts, func(t *testing.T) {
got, err := sanitize.HTML(ts)
if err != nil {
t.Fatal(err)
}
if got != ts {
t.Errorf("Got: %q, want: %q", got, ts)
}
})
}
}
// TestHTMLSimpleFormatting tests basic tags we should allow
func TestHTMLSimpleFormatting(t *testing.T) {
testStrings := []string{
"<p>paragraph</p>",
"<b>bold</b>",
"<i>italic</b>",
"<em>emphasis</em>",
"<strong>strong</strong>",
"<div><span>text</span></div>",
"<center>text</center>",
}
for _, ts := range testStrings {
t.Run(ts, func(t *testing.T) {
got, err := sanitize.HTML(ts)
if err != nil {
t.Fatal(err)
}
if got != ts {
t.Errorf("Got: %q, want: %q", got, ts)
}
})
}
}
// TestHTMLScriptTags tests some strings with JavaScript
func TestHTMLScriptTags(t *testing.T) {
testCases := []struct {
input, want string
}{
{
`safe<script>nope</script>`,
`safe`,
},
{
`<a onblur="alert(something)" href="http://mysite.com">mysite</a>`,
`<a href="http://mysite.com" rel="nofollow">mysite</a>`,
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got, err := sanitize.HTML(tc.input)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("Got: %q, want: %q", got, tc.want)
}
})
}
}
func TestSanitizeStyleTags(t *testing.T) {
testCases := []struct {
name, input, want string
}{
{
"empty",
``,
``,
},
{
"open",
`<div>`,
`<div>`,
},
{
"open close",
`<div></div>`,
`<div></div>`,
},
{
"inner text",
`<div>foo bar</div>`,
`<div>foo bar</div>`,
},
{
"self close",
`<br/>`,
`<br/>`,
},
{
"open params",
`<div id="me">`,
`<div id="me">`,
},
{
"open params squote",
`<div id="me" title='best'>`,
`<div id="me" title="best">`,
},
{
"open style",
`<div id="me" style="color: red;">`,
`<div id="me" style="color: red;">`,
},
{
"open style squote",
`<div id="me" style='color: red;'>`,
`<div id="me" style="color: red;">`,
},
{
"open style mixed case",
`<div id="me" StYlE="color: red;">`,
`<div id="me" style="color: red;">`,
},
{
"closed style",
`<br style="border: 1px solid red;"/>`,
`<br style="border: 1px solid red;"/>`,
},
{
"mixed case style",
`<br StYlE="border: 1px solid red;"/>`,
`<br style="border: 1px solid red;"/>`,
},
{
"mixed case invalid style",
`<br StYlE="position: fixed;"/>`,
`<br/>`,
},
{
"mixed",
`<p id='i' title="cla'zz" style="font-size: 25px;"><b>some text</b></p>`,
`<p id="i" title="cla&#39;zz" style="font-size: 25px;"><b>some text</b></p>`,
},
{
"invalid styles",
`<div id="me" style='position: absolute;'>`,
`<div id="me">`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := sanitize.HTML(tc.input)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("input: %s\ngot : %s\nwant: %s", tc.input, got, tc.want)
}
})
}
}