1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-18 10:07:02 +00:00

Compare commits

...

22 Commits

Author SHA1 Message Date
James Hillyerd
425a1349c6 Release 1.0-rc2 2013-11-07 11:00:54 -08:00
James Hillyerd
0f1e75b473 Make it possible to inject web DataStore for tests 2013-11-07 10:56:50 -08:00
James Hillyerd
d80521b24d Use MockDataStore for handler_test.go 2013-11-06 17:18:56 -08:00
James Hillyerd
ef48b9c2dd Extend ParseMailboxName()
- Checks for invalid characters, returns useful error if it finds them
- Extended unit tests for ParseMailboxName
- Closes #6
2013-11-06 15:36:46 -08:00
James Hillyerd
6b606ebb9b SMTP, logging changes
- smtpd/handler uses ParseEmailAddress() when opening mailbox and
  checking domainNoStore
- Added host info to logging for both SMTP and POP3, closes #16
2013-11-05 15:18:19 -08:00
James Hillyerd
962e995268 Travis CI config 2013-11-05 14:18:58 -08:00
James Hillyerd
a0a5a9acb0 Wire address validation into MAIL & RCPT handlers 2013-11-05 12:12:25 -08:00
James Hillyerd
596b268380 Completed ParseEmailAddress() 2013-11-04 17:02:17 -08:00
James Hillyerd
193a5f6f13 Commit incomplete ParseEmailAddress() 2013-11-04 16:18:30 -08:00
James Hillyerd
21ad7a2452 Checkpoint before converting Validator->Parser 2013-11-04 14:00:23 -08:00
James Hillyerd
bd4db645bb ValidateLocalPart: Handle backslash quoted chars 2013-11-03 20:53:37 -08:00
James Hillyerd
ba943cb682 Initial version of ValidateLocalPart
Does not support any quoting yet, not RFC compliant
2013-11-03 10:25:34 -08:00
James Hillyerd
b2c3c4ce0f More testing
Test DATA on handler
Add a 2 second wait if the test fails so that all the logging data can
be collected.
2013-10-30 14:42:55 -07:00
James Hillyerd
038f4fafd3 Capture log output during unit tests 2013-10-30 11:43:35 -07:00
James Hillyerd
f34a73f8a6 Domain validator + tests (unwired) 2013-10-29 20:40:47 -07:00
James Hillyerd
e5b6ed2230 Add many more SMTP handler tests 2013-10-29 17:01:54 -07:00
James Hillyerd
38c124875e Prevent panic when submitting an empty message 2013-10-29 17:01:21 -07:00
James Hillyerd
00243a622f Framework for testing SMTP over Pipe() connection 2013-10-29 13:58:21 -07:00
James Hillyerd
5ccd6b2044 Inversion of Control for smtpd.Server
Allow more control over how Server is instaniated so that it can be unit
tested.
2013-10-29 10:27:55 -07:00
James Hillyerd
ea6bb44969 Parse mailbox name before using/displaying it
Fixes #14
2013-10-27 14:52:24 -07:00
James Hillyerd
483ddc1c5e Track retained messages over time 2013-10-25 15:24:30 -07:00
James Hillyerd
47cba08c33 Add a Link button to messages
Allows users to copy the URL for a specific message and send it to
another person.
2013-10-25 13:29:26 -07:00
20 changed files with 1022 additions and 103 deletions

View File

@@ -8,6 +8,6 @@
"Include": "README*,LICENSE*,inbucket.bat,etc,themes"
},
"PackageVersion": "1.0",
"PrereleaseInfo": "rc1",
"PrereleaseInfo": "rc2",
"FormatVersion": "0.8"
}

9
.travis.yml Normal file
View File

@@ -0,0 +1,9 @@
language: go
go:
- 1.1
- tip
install:
- go get -v ./...
- go get github.com/stretchr/testify

View File

@@ -1,4 +1,4 @@
Inbucket
Inbucket [![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)](https://travis-ci.org/jhillyerd/inbucket)
========
Inbucket is an email testing service; it will accept messages for any email
@@ -21,7 +21,7 @@ Read more at the [Inbucket website](http://jhillyerd.github.io/inbucket/).
Development Status
------------------
Inbucket is currently beta quality: it works but is not well tested.
Inbucket is currently release-candidate quality: it is being used for real work.
Please check the [issues list](https://github.com/jhillyerd/inbucket/issues?state=open)
for more details.

View File

@@ -94,6 +94,9 @@ func main() {
fmt.Fprintf(pidf, "%v\n", os.Getpid())
}
// Grab our datastore
ds := smtpd.DefaultFileDataStore()
// Start HTTP server
go web.Start()
@@ -102,7 +105,7 @@ func main() {
go pop3Server.Start()
// Startup SMTP server, block until it exits
smtpServer = smtpd.New()
smtpServer = smtpd.NewSmtpServer(config.GetSmtpConfig(), ds)
smtpServer.Start()
// Wait for active connections to finish

View File

@@ -611,21 +611,21 @@ func (ses *Session) ooSeq(cmd string) {
// Session specific logging methods
func (ses *Session) logTrace(msg string, args ...interface{}) {
log.LogTrace("POP3<%v> %v", ses.id, fmt.Sprintf(msg, args...))
log.LogTrace("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logInfo(msg string, args ...interface{}) {
log.LogInfo("POP3<%v> %v", ses.id, fmt.Sprintf(msg, args...))
log.LogInfo("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.LogWarn("POP3<%v> %v", ses.id, fmt.Sprintf(msg, args...))
log.LogWarn("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.LogError("POP3<%v> %v", ses.id, fmt.Sprintf(msg, args...))
log.LogError("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}

View File

@@ -76,7 +76,10 @@ func DefaultFileDataStore() DataStore {
// 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) (Mailbox, error) {
name := ParseMailboxName(emailAddress)
name, err := ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
dir := HashMailboxName(name)
s1 := dir[0:3]
s2 := dir[0:6]
@@ -339,10 +342,10 @@ func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
func (m *FileMessage) ReadBody() (body *enmime.MIMEBody, err error) {
file, err := os.Open(m.rawPath())
defer file.Close()
if err != nil {
return nil, err
}
defer file.Close()
reader := bufio.NewReader(file)
msg, err := mail.ReadMessage(reader)
if err != nil {

View File

@@ -1,9 +1,12 @@
package smtpd
import (
"bytes"
"fmt"
"github.com/stretchr/testify/assert"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
@@ -12,7 +15,7 @@ import (
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
root := ds.path
@@ -85,11 +88,18 @@ func TestFSDirStructure(t *testing.T) {
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 := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
@@ -105,12 +115,19 @@ func TestFSAllMailboxes(t *testing.T) {
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 := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
mbName := "fred"
@@ -148,11 +165,18 @@ func TestFSDeliverMany(t *testing.T) {
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 := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
mbName := "fred"
@@ -216,11 +240,17 @@ func TestFSDelete(t *testing.T) {
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 := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
mbName := "fred"
@@ -257,11 +287,18 @@ func TestFSPurge(t *testing.T) {
}
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 := setupDataStore()
ds, logbuf := setupDataStore()
defer teardownDataStore(ds)
mbName := "fred"
@@ -288,15 +325,27 @@ func TestFSSize(t *testing.T) {
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)
}
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore() *FileDataStore {
func setupDataStore() (*FileDataStore, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
if err != nil {
panic(err)
}
return NewFileDataStore(path).(*FileDataStore)
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
return NewFileDataStore(path).(*FileDataStore), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning

View File

@@ -211,29 +211,35 @@ func (ss *Session) greetHandler(cmd string, arg string) {
// READY state -> waiting for MAIL
func (ss *Session) readyHandler(cmd string, arg string) {
if cmd == "MAIL" {
// (?i) makes the regex case insensitive
re := regexp.MustCompile("(?i)^FROM:\\s*<([^>]+)>( [\\w= ]+)?$")
// 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: '%v'", arg)
ss.logWarn("Bad MAIL argument: %q", arg)
return
}
from := m[1]
if _, _, err := 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
// ready the DATA as bytes, so it does not effect our processing.
// 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: '%v'", arg)
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 '%v' as an integer", args["SIZE"])
ss.logWarn("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > ss.server.maxMessageBytes {
@@ -259,11 +265,16 @@ func (ss *Session) mailHandler(cmd string, arg string) {
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: '%v'", arg)
ss.logWarn("Bad RCPT argument: %q", arg)
return
}
// This trim is probably too forgiving
recip := strings.Trim(arg[3:], "<> ")
if _, _, err := 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))
@@ -276,7 +287,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
case "DATA":
if arg != "" {
ss.send("501 DATA command should not have any arguments")
ss.logWarn("Got unexpected args on DATA: '%v'", arg)
ss.logWarn("Got unexpected args on DATA: %q", arg)
return
}
if ss.recipients.Len() > 0 {
@@ -303,19 +314,26 @@ func (ss *Session) dataHandler() {
i := 0
for e := ss.recipients.Front(); e != nil; e = e.Next() {
recip := e.Value.(string)
if !strings.HasSuffix(strings.ToLower(recip), "@"+ss.server.domainNoStore) {
local, domain, err := 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(recip)
mb, err := ss.server.dataStore.MailboxFor(local)
if err != nil {
ss.logError("Failed to open mailbox for %v", recip)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip))
ss.logError("Failed to open mailbox for %q", local)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", local))
ss.reset()
return
}
mailboxes[i] = mb
messages[i] = mb.NewMessage()
} else {
log.LogTrace("Not storing message for '%v'", recip)
log.LogTrace("Not storing message for %q", recip)
}
i++
}
@@ -409,7 +427,7 @@ func (ss *Session) send(msg string) {
}
if _, err := fmt.Fprint(ss.conn, msg+"\r\n"); err != nil {
ss.sendError = err
ss.logWarn("Failed to send: '%v'", msg)
ss.logWarn("Failed to send: %q", msg)
return
}
ss.logTrace(">> %v >>", msg)
@@ -462,19 +480,19 @@ func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
case l == 0:
return "", "", true
case l < 4:
ss.logWarn("Command too short: '%v'", line)
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: '%v'", line)
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: '%v'", line)
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
@@ -491,7 +509,7 @@ func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
re := regexp.MustCompile(" (\\w+)=(\\w+)")
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
ss.logWarn("Failed to parse arg string: '%v'")
ss.logWarn("Failed to parse arg string: %q")
return nil, false
}
for _, m := range pm {
@@ -514,21 +532,21 @@ func (ss *Session) ooSeq(cmd string) {
// Session specific logging methods
func (ss *Session) logTrace(msg string, args ...interface{}) {
log.LogTrace("SMTP<%v> %v", ss.id, fmt.Sprintf(msg, args...))
log.LogTrace("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logInfo(msg string, args ...interface{}) {
log.LogInfo("SMTP<%v> %v", ss.id, fmt.Sprintf(msg, args...))
log.LogInfo("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.LogWarn("SMTP<%v> %v", ss.id, fmt.Sprintf(msg, args...))
log.LogWarn("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.LogError("SMTP<%v> %v", ss.id, fmt.Sprintf(msg, args...))
log.LogError("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}

380
smtpd/handler_test.go Normal file
View File

@@ -0,0 +1,380 @@
package smtpd
import (
"bytes"
"fmt"
"github.com/jhillyerd/inbucket/config"
"io"
//"io/ioutil"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
)
type scriptStep struct {
send string
expect int
}
// Test commands in GREET state
func TestGreetState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
// Test out some mangled HELOs
script = []scriptStep{
{"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", 250}}); err != nil {
t.Error(err)
}
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 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 := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
// 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 := &MockDataStore{}
mb1 := &MockMailbox{}
msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1)
msg1.On("Close").Return(nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
// 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 := &MockDataStore{}
mb1 := &MockMailbox{}
msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1)
msg1.On("Close").Return(nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
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)
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.ReadCodeLine(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) (*Server, *bytes.Buffer) {
// 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
return NewSmtpServer(cfg, ds), buf
}
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
}
func teardownSmtpServer(server *Server) {
//log.SetOutput(os.Stderr)
}

View File

@@ -46,9 +46,7 @@ var expErrorsHist = new(expvar.String)
var expWarnsHist = new(expvar.String)
// Init a new Server object
func New() *Server {
ds := DefaultFileDataStore()
cfg := config.GetSmtpConfig()
func NewSmtpServer(cfg config.SmtpConfig, ds DataStore) *Server {
return &Server{dataStore: ds, domain: cfg.Domain, maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds, maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages, domainNoStore: strings.ToLower(cfg.DomainNoStore),
@@ -141,6 +139,7 @@ func metricsTicker(t *time.Ticker) {
expErrorsHist.Set(pushMetric(errorsHist, expErrorsTotal))
expWarnsHist.Set(pushMetric(warnsHist, expWarnsTotal))
expRetentionDeletesHist.Set(pushMetric(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(pushMetric(retainedHist, expRetainedCurrent))
}
}

View File

@@ -14,12 +14,15 @@ var retentionScanCompletedMu sync.RWMutex
var expRetentionDeletesTotal = new(expvar.Int)
var expRetentionPeriod = new(expvar.Int)
var expRetainedCurrent = new(expvar.Int)
// History of certain stats
var retentionDeletesHist = list.New()
var retainedHist = list.New()
// History rendered as comma delim string
var expRetentionDeletesHist = new(expvar.String)
var expRetainedHist = new(expvar.String)
func StartRetentionScanner(ds DataStore) {
cfg := config.GetDataStoreConfig()
@@ -62,6 +65,7 @@ func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) er
return err
}
retained := 0
for _, mb := range mboxes {
messages, err := mb.GetMessages()
if err != nil {
@@ -77,6 +81,8 @@ func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) er
} else {
expRetentionDeletesTotal.Add(1)
}
} else {
retained++
}
}
// Sleep after completing a mailbox
@@ -84,6 +90,7 @@ func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) er
}
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
return nil
}
@@ -112,4 +119,6 @@ func init() {
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
}

View File

@@ -69,7 +69,8 @@ type MockDataStore struct {
}
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
return nil, nil
args := m.Called()
return args.Get(0).(Mailbox), args.Error(1)
}
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
@@ -158,8 +159,8 @@ func (m *MockMessage) Size() int64 {
}
func (m *MockMessage) Append(data []byte) error {
args := m.Called(data)
return args.Error(0)
// []byte arg seems to mess up testify/mock
return nil
}
func (m *MockMessage) Close() error {

View File

@@ -1,6 +1,7 @@
package smtpd
import (
"bytes"
"container/list"
"crypto/sha1"
"fmt"
@@ -8,16 +9,36 @@ import (
"strings"
)
// Take "user+ext@host.com" and return "user", aka the mailbox we'll store it in
func ParseMailboxName(emailAddress string) (result string) {
result = strings.ToLower(emailAddress)
if idx := strings.Index(result, "@"); idx > -1 {
result = result[0:idx]
// Take "user+ext" and return "user", aka the mailbox we'll store it in
// Return error if it contains invalid characters, we don't accept anything
// 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
return result, nil
}
// Take a mailbox name and hash it into the directory we'll store it in
@@ -38,3 +59,156 @@ func JoinStringList(listOfStrings *list.List) string {
}
return strings.Join(s, ",")
}
// 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
hasLetters := false
for _, c := range domain {
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c == '_':
// Must contain some of these to be a valid label
hasLetters = true
labelLen++
case '0' <= c && c <= '9':
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 !hasLetters {
return false
}
labelLen = 0
hasLetters = 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
buf.WriteByte(c)
inCharQuote = false
case '0' <= c && c <= '9':
// Numbers are OK
buf.WriteByte(c)
inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted
buf.WriteByte(c)
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")
}
buf.WriteByte(c)
inCharQuote = false
case c == '\\':
inCharQuote = true
case c == '"':
if inCharQuote {
buf.WriteByte(c)
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 {
buf.WriteByte(c)
inCharQuote = false
} else {
// End of local-part
if i > 63 {
return "", "", fmt.Errorf("Local part must not exceed 64 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 {
buf.WriteByte(c)
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
}

View File

@@ -2,15 +2,211 @@ package smtpd
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestParseMailboxName(t *testing.T) {
assert.Equal(t, ParseMailboxName("MailBOX"), "mailbox")
assert.Equal(t, ParseMailboxName("MailBox@Host.Com"), "mailbox")
assert.Equal(t, ParseMailboxName("Mail+extra@Host.Com"), "mail")
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"},
{"google..com", false, "Double dot not valid"},
{".foo.com", false, "Cannot start with a dot"},
{"mail.123.com", false, "Number only label not valid"},
{"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", 65), false, "Only valid up to 64 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)
}
}
}

View File

@@ -1,21 +1,22 @@
{{$name := .name}}
{{$id := .message.Id}}
<div id="emailActions">
<a href="javascript:deleteMessage('{{.message.Id}}');">Delete</a>
<a href="javascript:messageSource('{{.message.Id}}');">Source</a>
{{if .htmlAvailable}}
<a href="javascript:htmlView('{{.message.Id}}');">HTML</a>
{{end}}
<a href="/link/{{$name}}/{{$id}}">Link</a>
<a href="javascript:deleteMessage('{{.message.Id}}');">Delete</a>
<a href="javascript:messageSource('{{.message.Id}}');">Source</a>
{{if .htmlAvailable}}
<a href="javascript:htmlView('{{.message.Id}}');">HTML</a>
{{end}}
</div>
<table id="emailHeader">
<tr>
<th>From:</th>
<td>{{.message.From}}</td>
</tr>
<tr>
<th>Date:</th>
<td>{{.message.Date}}</td>
</tr>
<tr>
<th>From:</th>
<td>{{.message.From}}</td>
</tr>
<tr>
<th>Date:</th>
<td>{{.message.Date}}</td>
</tr>
<table>
{{with .attachments}}

View File

@@ -3,7 +3,8 @@
{{define "script"}}
<script>
function listLoaded() {
var selected = "{{.selected}}"
function listLoaded() {
$('.listEntry').hover(
function() {
$(this).addClass("listEntryHover")
@@ -19,6 +20,10 @@
}
)
$("#messageList").slideDown()
if (selected != "") {
$("#" + selected).click()
selected = ""
}
}
function loadList() {

View File

@@ -53,7 +53,8 @@
}
}
function setHistory(name, value) {
// Show spikes for numbers that only increase
function setHistoryOfActivity(name, value) {
var h = value.split(",")
var prev = parseInt(h[0])
for (i=0; i<h.length; i++) {
@@ -71,6 +72,15 @@
}
}
// Show up/down for numbers that can decrease
function setHistoryOfCount(name, value) {
var h = value.split(",")
el = $('#s-' + name)
if (el) {
el.sparkline(h)
}
}
function metric(name, value, filter, chartable) {
if (chartable) {
appendHistory(name, value)
@@ -101,15 +111,17 @@
// Server-side history
metric('smtpReceivedTotal', data.smtp.ReceivedTotal, numberFilter, false)
setHistory('smtpReceivedTotal', data.smtp.ReceivedHist)
setHistoryOfActivity('smtpReceivedTotal', data.smtp.ReceivedHist)
metric('smtpConnectsTotal', data.smtp.ConnectsTotal, numberFilter, false)
setHistory('smtpConnectsTotal', data.smtp.ConnectsHist)
setHistoryOfActivity('smtpConnectsTotal', data.smtp.ConnectsHist)
metric('smtpWarnsTotal', data.smtp.WarnsTotal, numberFilter, false)
setHistory('smtpWarnsTotal', data.smtp.WarnsHist)
setHistoryOfActivity('smtpWarnsTotal', data.smtp.WarnsHist)
metric('smtpErrorsTotal', data.smtp.ErrorsTotal, numberFilter, false)
setHistory('smtpErrorsTotal', data.smtp.ErrorsHist)
setHistoryOfActivity('smtpErrorsTotal', data.smtp.ErrorsHist)
metric('retentionDeletesTotal', data.retention.DeletesTotal, numberFilter, false)
setHistory('retentionDeletesTotal', data.retention.DeletesHist)
setHistoryOfActivity('retentionDeletesTotal', data.retention.DeletesHist)
metric('retainedCurrent', data.retention.RetainedCurrent, numberFilter, false)
setHistoryOfCount('retainedCurrent', data.retention.RetainedHist)
}
function loadMetrics() {
@@ -233,21 +245,21 @@
<tr>
<th>Retention Period:</th>
<td colspan="3">
{{if .retentionMinutes}}
<span id="m-retentionPeriod">.</span>
{{else}}
Disabled
{{end}}
{{if .retentionMinutes}}
<span id="m-retentionPeriod">.</span>
{{else}}
Disabled
{{end}}
</td>
</tr>
<tr>
<th>Retention Scan:</th>
<td colspan="3">
{{if .retentionMinutes}}
Completed <span id="m-retentionScanCompleted">.</span> ago
{{else}}
Disabled
{{end}}
{{if .retentionMinutes}}
Completed <span id="m-retentionScanCompleted">.</span> ago
{{else}}
Disabled
{{end}}
</td>
</tr>
<tr>
@@ -256,6 +268,12 @@
<td class="sparkline"><span id="s-retentionDeletesTotal"></span></td>
<td>(60min)</td>
</tr>
<tr>
<th>Currently Retained:</th>
<td><span id="m-retainedCurrent">.</span></td>
<td class="sparkline"><span id="s-retainedCurrent"></span></td>
<td>(60min)</td>
</tr>
</table>
<p class="last">&nbsp;</p>
</div>

View File

@@ -39,11 +39,10 @@ func headerMatch(req *http.Request, name string, value string) bool {
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
ds := smtpd.DefaultFileDataStore()
ctx := &Context{
Vars: vars,
Session: sess,
DataStore: ds,
DataStore: DataStore,
IsJson: headerMatch(req, "Accept", "application/json"),
}
if err != nil {

View File

@@ -3,6 +3,7 @@ package web
import (
"fmt"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"html/template"
"io"
"net/http"
@@ -30,23 +31,51 @@ type JsonMessageBody struct {
}
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *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")
http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err = smtpd.ParseMailboxName(name)
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther)
return nil
}
return RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"ctx": ctx,
"name": name,
"selected": selected,
})
}
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther)
return nil
}
uri := fmt.Sprintf("%s?name=%s&id=%s", reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return fmt.Errorf("Failed to get mailbox for %v: %v", name, err)
@@ -81,9 +110,11 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *Context) (err er
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return fmt.Errorf("MailboxFor('%v'): %v", name, err)
@@ -133,8 +164,10 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *Context) (err er
func MailboxPurge(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return fmt.Errorf("MailboxFor('%v'): %v", name, err)
@@ -155,9 +188,11 @@ func MailboxPurge(w http.ResponseWriter, req *http.Request, ctx *Context) (err e
func MailboxHtml(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return err
@@ -182,9 +217,11 @@ func MailboxHtml(w http.ResponseWriter, req *http.Request, ctx *Context) (err er
func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return err
@@ -205,8 +242,13 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *Context) (err
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther)
return nil
}
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
@@ -242,7 +284,12 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *Contex
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
http.Redirect(w, req, reverse("RootIndex"), http.StatusSeeOther)
return nil
}
id := ctx.Vars["id"]
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
@@ -278,9 +325,11 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *Context) (
func MailboxDelete(w http.ResponseWriter, req *http.Request, ctx *Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name := ctx.Vars["name"]
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
return err

View File

@@ -10,6 +10,7 @@ import (
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"net"
"net/http"
"time"
@@ -17,6 +18,7 @@ import (
type handler func(http.ResponseWriter, *http.Request, *Context) error
var DataStore smtpd.DataStore
var Router *mux.Router
var listener net.Listener
var sessionStore sessions.Store
@@ -34,6 +36,7 @@ func setupRoutes(cfg config.WebConfig) {
// Root
r.Path("/").Handler(handler(RootIndex)).Name("RootIndex").Methods("GET")
r.Path("/status").Handler(handler(RootStatus)).Name("RootStatus").Methods("GET")
r.Path("/link/{name}/{id}").Handler(handler(MailboxLink)).Name("MailboxLink").Methods("GET")
r.Path("/mailbox").Handler(handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
r.Path("/mailbox/{name}").Handler(handler(MailboxList)).Name("MailboxList").Methods("GET")
r.Path("/mailbox/{name}").Handler(handler(MailboxPurge)).Name("MailboxPurge").Methods("DELETE")
@@ -54,6 +57,9 @@ func Start() {
cfg := config.GetWebConfig()
setupRoutes(cfg)
// NewContext() will use this DataStore for the web handlers
DataStore = smtpd.DefaultFileDataStore()
// TODO Make configurable
sessionStore = sessions.NewCookieStore([]byte("something-very-secret"))