From 05d9c9ddeb3355bfa431fa4e9a02c5647360be99 Mon Sep 17 00:00:00 2001 From: Ian Kent Date: Sat, 19 Apr 2014 23:37:11 +0100 Subject: [PATCH] Clean up message parsing and add tests --- mailhog/data/message.go | 103 ++++++++++++++++++++++++ mailhog/smtp/{smtp.go => session.go} | 46 +++++++---- mailhog/storage/mongodb.go | 41 ++++++++++ main_test.go | 114 +++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 mailhog/data/message.go rename mailhog/smtp/{smtp.go => session.go} (77%) create mode 100644 mailhog/storage/mongodb.go create mode 100644 main_test.go diff --git a/mailhog/data/message.go b/mailhog/data/message.go new file mode 100644 index 0000000..7d46b45 --- /dev/null +++ b/mailhog/data/message.go @@ -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, + } +} diff --git a/mailhog/smtp/smtp.go b/mailhog/smtp/session.go similarity index 77% rename from mailhog/smtp/smtp.go rename to mailhog/smtp/session.go index b277683..d742fe2 100644 --- a/mailhog/smtp/smtp.go +++ b/mailhog/smtp/session.go @@ -6,7 +6,10 @@ import ( "log" "net" "strings" + "regexp" "github.com/ian-kent/MailHog/mailhog" + "github.com/ian-kent/MailHog/mailhog/storage" + "github.com/ian-kent/MailHog/mailhog/data" ) type Session struct { @@ -14,7 +17,7 @@ type Session struct { line string conf *mailhog.Config state int - message *Message + message *data.SMTPMessage } const ( @@ -25,15 +28,11 @@ const ( DONE ) -type Message struct { - From string - To []string - Data string - Helo string -} +// TODO add Received/Return-Path headers +// TODO replace ".." lines with . in data 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.Write("220", conv.conf.Hostname + " ESMTP Go-MailHog") conv.Read() @@ -77,8 +76,15 @@ func (c *Session) Parse() { if c.state == DATA { c.message.Data += parts[0] + "\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.Write("250", "Ok: queued as nnnnnnnn") + if err != nil { + // FIXME + c.Write("500", "Error") + return + } + c.Write("250", "Ok: queued as " + id) } } else { c.Process(strings.Trim(parts[0], "\r\n")) @@ -111,7 +117,7 @@ func (c *Session) Process(line string) { case command == "RSET": c.log("Got RSET command, switching to ESTABLISH state") c.state = ESTABLISH - c.message = &Message{} + c.message = &data.SMTPMessage{} c.Write("250", "Ok") case command == "NOOP": c.log("Got NOOP command") @@ -119,6 +125,10 @@ func (c *Session) Process(line string) { case command == "QUIT": c.log("Got QUIT command") c.Write("221", "Bye") + err := c.conn.Close() + if err != nil { + c.log("Error closing connection") + } case c.state == ESTABLISH: switch command { case "HELO": @@ -138,10 +148,11 @@ func (c *Session) Process(line string) { switch command { case "MAIL": c.log("Got MAIL command, switching to RCPT state") - // TODO parse args - c.message.From = args + r, _ := regexp.Compile("From:<([^>]+)>") + match := r.FindStringSubmatch(args) + c.message.From = match[1] c.state = RCPT - c.Write("250", "Ok") + c.Write("250", "Sender " + match[1] + " ok") default: c.log("Got unknown command for MAIL state: '%s'", command) } @@ -149,8 +160,11 @@ func (c *Session) Process(line string) { switch command { case "RCPT": c.log("Got RCPT command") - c.message.To = append(c.message.To, args) - c.Write("250", "Ok") + r, _ := regexp.Compile("To:<([^>]+)>") + 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": c.log("Got DATA command, switching to DATA state") c.state = DATA @@ -160,12 +174,14 @@ func (c *Session) Process(line string) { } case c.state == DONE: switch command { + /* case "MAIL": c.log("Got MAIL command") // TODO parse args c.message.From = args c.state = RCPT c.Write("250", "Ok") + */ default: c.log("Got unknown command for DONE state: '%s'", command) } diff --git a/mailhog/storage/mongodb.go b/mailhog/storage/mongodb.go new file mode 100644 index 0000000..3ac11ed --- /dev/null +++ b/mailhog/storage/mongodb.go @@ -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; +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..30b4b39 --- /dev/null +++ b/main_test.go @@ -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:\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:\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 .\n") + + // Send the message + content := "Content-Type: text/plain\r\n" + content += "Content-Length: 220\r\n" + content += "From: Nobody \r\n" + content += "To: Someone \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 "}, "To is Someone ") + assert.Equal(t, message.Content.Headers["From"], []string{"Nobody "}, "From is Nobody ") + 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{""}, "Return-Path is ") + 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") +}