Clean up message parsing and add tests

This commit is contained in:
Ian Kent 2014-04-19 23:37:11 +01:00
parent b5f41ce7b9
commit 05d9c9ddeb
4 changed files with 289 additions and 15 deletions

103
mailhog/data/message.go Normal file
View file

@ -0,0 +1,103 @@
package data;
import (
"log"
"strings"
"time"
"labix.org/v2/mgo/bson"
"github.com/ian-kent/MailHog/mailhog"
)
type Message struct {
Id string
From *Path
To []*Path
Content *Content
Created time.Time
}
type Path struct {
Relays []string
Mailbox string
Domain string
Params string
}
type Content struct {
Headers map[string][]string
Body string
Size int
}
type SMTPMessage struct {
From string
To []string
Data string
Helo string
}
func ParseSMTPMessage(c *mailhog.Config, m *SMTPMessage) *Message {
arr := make([]*Path, 0)
for _, path := range m.To {
arr = append(arr, PathFromString(path))
}
msg := &Message{
Id: bson.NewObjectId().Hex(),
From: PathFromString(m.From),
To: arr,
Content: ContentFromString(m.Data),
Created: time.Now(),
}
msg.Content.Headers["Message-ID"] = []string{msg.Id + "@" + c.Hostname} // FIXME
msg.Content.Headers["Received"] = []string{"from " + m.Helo + " by " + c.Hostname + " (Go-MailHog)"} // FIXME
msg.Content.Headers["Return-Path"] = []string{"<" + m.From + ">"}
return msg
}
func PathFromString(path string) *Path {
relays := make([]string, 0)
email := path
if(strings.Contains(path, ":")) {
x := strings.SplitN(path, ":", 2)
r, e := x[0], x[1]
email = e
relays = strings.Split(r, ",")
}
mailbox, domain := "", ""
if(strings.Contains(email, "@")) {
x := strings.SplitN(email, "@", 2)
mailbox, domain = x[0], x[1]
} else {
mailbox = email
}
return &Path{
Relays: relays,
Mailbox: mailbox,
Domain: domain,
Params: "", // FIXME?
}
}
func ContentFromString(data string) *Content {
x := strings.Split(data, "\r\n\r\n")
headers, body := x[0], x[1]
h := make(map[string][]string, 0)
hdrs := strings.Split(headers, "\r\n")
for _, hdr := range hdrs {
if(strings.Contains(hdr, ": ")) {
y := strings.SplitN(hdr, ": ", 2)
key, value := y[0], y[1]
h[key] = []string{value}
} else {
log.Printf("Found invalid header: '%s'", hdr)
}
}
return &Content{
Size: len(data),
Headers: h,
Body: body,
}
}

View file

@ -6,7 +6,10 @@ import (
"log" "log"
"net" "net"
"strings" "strings"
"regexp"
"github.com/ian-kent/MailHog/mailhog" "github.com/ian-kent/MailHog/mailhog"
"github.com/ian-kent/MailHog/mailhog/storage"
"github.com/ian-kent/MailHog/mailhog/data"
) )
type Session struct { type Session struct {
@ -14,7 +17,7 @@ type Session struct {
line string line string
conf *mailhog.Config conf *mailhog.Config
state int state int
message *Message message *data.SMTPMessage
} }
const ( const (
@ -25,15 +28,11 @@ const (
DONE DONE
) )
type Message struct { // TODO add Received/Return-Path headers
From string // TODO replace ".." lines with . in data
To []string
Data string
Helo string
}
func StartSession(conn *net.TCPConn, conf *mailhog.Config) { func StartSession(conn *net.TCPConn, conf *mailhog.Config) {
conv := &Session{conn, "", conf, ESTABLISH, &Message{}} conv := &Session{conn, "", conf, ESTABLISH, &data.SMTPMessage{}}
conv.log("Starting session") conv.log("Starting session")
conv.Write("220", conv.conf.Hostname + " ESMTP Go-MailHog") conv.Write("220", conv.conf.Hostname + " ESMTP Go-MailHog")
conv.Read() conv.Read()
@ -77,8 +76,15 @@ func (c *Session) Parse() {
if c.state == DATA { if c.state == DATA {
c.message.Data += parts[0] + "\n" c.message.Data += parts[0] + "\n"
if(strings.HasSuffix(c.message.Data, "\r\n.\r\n")) { if(strings.HasSuffix(c.message.Data, "\r\n.\r\n")) {
c.message.Data = strings.TrimSuffix(c.message.Data, "\r\n.\r\n")
id, err := storage.Store(c.conf, c.message)
c.state = DONE c.state = DONE
c.Write("250", "Ok: queued as nnnnnnnn") if err != nil {
// FIXME
c.Write("500", "Error")
return
}
c.Write("250", "Ok: queued as " + id)
} }
} else { } else {
c.Process(strings.Trim(parts[0], "\r\n")) c.Process(strings.Trim(parts[0], "\r\n"))
@ -111,7 +117,7 @@ func (c *Session) Process(line string) {
case command == "RSET": case command == "RSET":
c.log("Got RSET command, switching to ESTABLISH state") c.log("Got RSET command, switching to ESTABLISH state")
c.state = ESTABLISH c.state = ESTABLISH
c.message = &Message{} c.message = &data.SMTPMessage{}
c.Write("250", "Ok") c.Write("250", "Ok")
case command == "NOOP": case command == "NOOP":
c.log("Got NOOP command") c.log("Got NOOP command")
@ -119,6 +125,10 @@ func (c *Session) Process(line string) {
case command == "QUIT": case command == "QUIT":
c.log("Got QUIT command") c.log("Got QUIT command")
c.Write("221", "Bye") c.Write("221", "Bye")
err := c.conn.Close()
if err != nil {
c.log("Error closing connection")
}
case c.state == ESTABLISH: case c.state == ESTABLISH:
switch command { switch command {
case "HELO": case "HELO":
@ -138,10 +148,11 @@ func (c *Session) Process(line string) {
switch command { switch command {
case "MAIL": case "MAIL":
c.log("Got MAIL command, switching to RCPT state") c.log("Got MAIL command, switching to RCPT state")
// TODO parse args r, _ := regexp.Compile("From:<([^>]+)>")
c.message.From = args match := r.FindStringSubmatch(args)
c.message.From = match[1]
c.state = RCPT c.state = RCPT
c.Write("250", "Ok") c.Write("250", "Sender " + match[1] + " ok")
default: default:
c.log("Got unknown command for MAIL state: '%s'", command) c.log("Got unknown command for MAIL state: '%s'", command)
} }
@ -149,8 +160,11 @@ func (c *Session) Process(line string) {
switch command { switch command {
case "RCPT": case "RCPT":
c.log("Got RCPT command") c.log("Got RCPT command")
c.message.To = append(c.message.To, args) r, _ := regexp.Compile("To:<([^>]+)>")
c.Write("250", "Ok") match := r.FindStringSubmatch(args)
c.message.To = append(c.message.To, match[1])
c.state = RCPT
c.Write("250", "Recipient " + match[1] + " ok")
case "DATA": case "DATA":
c.log("Got DATA command, switching to DATA state") c.log("Got DATA command, switching to DATA state")
c.state = DATA c.state = DATA
@ -160,12 +174,14 @@ func (c *Session) Process(line string) {
} }
case c.state == DONE: case c.state == DONE:
switch command { switch command {
/*
case "MAIL": case "MAIL":
c.log("Got MAIL command") c.log("Got MAIL command")
// TODO parse args // TODO parse args
c.message.From = args c.message.From = args
c.state = RCPT c.state = RCPT
c.Write("250", "Ok") c.Write("250", "Ok")
*/
default: default:
c.log("Got unknown command for DONE state: '%s'", command) c.log("Got unknown command for DONE state: '%s'", command)
} }

View file

@ -0,0 +1,41 @@
package storage
import (
"log"
"labix.org/v2/mgo"
"labix.org/v2/mgo/bson"
"github.com/ian-kent/MailHog/mailhog/data"
"github.com/ian-kent/MailHog/mailhog"
)
func Store(c *mailhog.Config, m *data.SMTPMessage) (string, error) {
msg := data.ParseSMTPMessage(c, m)
session, err := mgo.Dial("localhost:27017")
if(err != nil) {
log.Printf("Error connecting to MongoDB: %s", err)
return "", err
}
defer session.Close()
err = session.DB("mailhog").C("messages").Insert(msg)
if err != nil {
log.Printf("Error inserting message: %s", err)
return "", err
}
return msg.Id, nil
}
func Load(id string) (*data.Message, error) {
session, err := mgo.Dial("localhost:27017")
if(err != nil) {
log.Printf("Error connecting to MongoDB: %s", err)
return nil, err
}
defer session.Close()
result := &data.Message{}
err = session.DB("mailhog").C("messages").Find(bson.M{"id": id}).One(&result)
if err != nil {
log.Printf("Error loading message: %s", err)
return nil, err
}
return result, nil;
}

114
main_test.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"net"
"regexp"
"github.com/ian-kent/MailHog/mailhog/storage"
)
// FIXME requires a running instance of MailHog
func TestBasicHappyPath(t *testing.T) {
buf := make([]byte, 1024)
// Open a connection
conn, err := net.Dial("tcp", "127.0.0.1:1025")
assert.Nil(t, err)
// Read the greeting
n, err := conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "220 mailhog.example ESMTP Go-MailHog\n")
// Send EHLO
_, err = conn.Write([]byte("EHLO localhost\r\n"))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "250 Hello localhost\n")
// Send MAIL
_, err = conn.Write([]byte("MAIL From:<nobody@mailhog.example>\r\n"))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "250 Sender nobody@mailhog.example ok\n")
// Send RCPT
_, err = conn.Write([]byte("RCPT To:<someone@mailhog.example>\r\n"))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "250 Recipient someone@mailhog.example ok\n")
// Send DATA
_, err = conn.Write([]byte("DATA\r\n"))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "354 End data with <CR><LF>.<CR><LF>\n")
// Send the message
content := "Content-Type: text/plain\r\n"
content += "Content-Length: 220\r\n"
content += "From: Nobody <nobody@mailhog.example>\r\n"
content += "To: Someone <someone@mailhog.example>\r\n"
content += "\r\n"
content += "Hi there :)\r\n"
content += ".\r\n"
_, err = conn.Write([]byte(content))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
r, _ := regexp.Compile("250 Ok: queued as ([0-9a-f]+)\n")
match := r.FindStringSubmatch(string(buf[0:n]))
assert.NotNil(t, match)
// Send QUIT
_, err = conn.Write([]byte("QUIT\r\n"))
assert.Nil(t, err)
// Read the response
n, err = conn.Read(buf)
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "221 Bye\n")
message, err := storage.Load(match[1])
assert.Nil(t, err)
assert.NotNil(t, message)
assert.Equal(t, message.From.Domain, "mailhog.example", "sender domain is mailhog.example")
assert.Equal(t, message.From.Mailbox, "nobody", "sender mailbox is nobody")
assert.Equal(t, message.From.Params, "", "sender params is empty")
assert.Equal(t, len(message.From.Relays), 0, "sender has no relays")
assert.Equal(t, len(message.To), 1, "message has 1 recipient")
assert.Equal(t, message.To[0].Domain, "mailhog.example", "recipient domain is mailhog.example")
assert.Equal(t, message.To[0].Mailbox, "someone", "recipient mailbox is someone")
assert.Equal(t, message.To[0].Params, "", "recipient params is empty")
assert.Equal(t, len(message.To[0].Relays), 0, "recipient has no relays")
assert.Equal(t, len(message.Content.Headers), 7, "message has 7 headers")
assert.Equal(t, message.Content.Headers["Content-Type"], []string{"text/plain"}, "Content-Type header is text/plain")
assert.Equal(t, message.Content.Headers["Content-Length"], []string{"220"}, "Content-Length is 220")
assert.Equal(t, message.Content.Headers["To"], []string{"Someone <someone@mailhog.example>"}, "To is Someone <someone@mailhog.example>")
assert.Equal(t, message.Content.Headers["From"], []string{"Nobody <nobody@mailhog.example>"}, "From is Nobody <nobody@mailhog.example>")
assert.Equal(t, message.Content.Headers["Received"], []string{"from localhost by mailhog.example (Go-MailHog)"}, "Received is from localhost by mailhog.example (Go-MailHog)")
assert.Equal(t, message.Content.Headers["Return-Path"], []string{"<nobody@mailhog.example>"}, "Return-Path is <nobody@mailhog.example>")
assert.Equal(t, message.Content.Headers["Message-ID"], []string{match[1] + "@mailhog.example"}, "Message-ID is " + match[1] + "@mailhog.example")
assert.Equal(t, message.Content.Body, "Hi there :)", "message has correct body")
}