From ef941fb69c8bc3d2ff3b5134af0aca6d687ab182 Mon Sep 17 00:00:00 2001 From: Ian Kent Date: Mon, 21 Apr 2014 12:14:47 +0100 Subject: [PATCH] MIME detection and add some error responses --- mailhog/data/message.go | 25 ++++++- mailhog/smtp/session.go | 12 +++- main_test.go | 5 +- mime_test.go | 143 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 mime_test.go diff --git a/mailhog/data/message.go b/mailhog/data/message.go index ca6676d..02197cf 100644 --- a/mailhog/data/message.go +++ b/mailhog/data/message.go @@ -4,6 +4,7 @@ import ( "log" "strings" "time" + "regexp" "labix.org/v2/mgo/bson" "github.com/ian-kent/MailHog/mailhog" ) @@ -38,6 +39,10 @@ type SMTPMessage struct { Helo string } +type MIMEBody struct { + Parts []Content +} + func ParseSMTPMessage(c *mailhog.Config, m *SMTPMessage) *Message { arr := make([]*Path, 0) for _, path := range m.To { @@ -50,12 +55,28 @@ func ParseSMTPMessage(c *mailhog.Config, m *SMTPMessage) *Message { Content: ContentFromString(m.Data), Created: time.Now(), } - msg.Content.Headers["Message-ID"] = []string{msg.Id + "@" + c.Hostname} // FIXME + log.Printf("Is MIME: %t\n", msg.Content.IsMIME()); + msg.Content.Headers["Message-ID"] = []string{msg.Id + "@" + c.Hostname} msg.Content.Headers["Received"] = []string{"from " + m.Helo + " by " + c.Hostname + " (Go-MailHog)\r\n id " + msg.Id + "@" + c.Hostname + "; " + time.Now().Format(time.RFC1123Z)} msg.Content.Headers["Return-Path"] = []string{"<" + m.From + ">"} return msg } +func (content *Content) IsMIME() bool { + if strings.HasPrefix(content.Headers["Content-Type"][0], "multipart/") { + return true + } else { + return false + } +} + +func (content *Content) ParseMIMEBody() *MIMEBody { + re := regexp.MustCompile("boundary=\"([^\"]+)\"") + match := re.FindStringSubmatch(content.Body) + log.Printf("Got boundary: %s", match[1]) + return nil +} + func PathFromString(path string) *Path { relays := make([]string, 0) email := path @@ -82,7 +103,7 @@ func PathFromString(path string) *Path { } func ContentFromString(data string) *Content { - x := strings.Split(data, "\r\n\r\n") + x := strings.SplitN(data, "\r\n\r\n", 2) headers, body := x[0], x[1] h := make(map[string][]string, 0) diff --git a/mailhog/smtp/session.go b/mailhog/smtp/session.go index 17887a5..cdd58b6 100644 --- a/mailhog/smtp/session.go +++ b/mailhog/smtp/session.go @@ -76,12 +76,14 @@ 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.log("Got EOF, storing message and switching to MAIL state") + //c.log("Full message data: %s", c.message.Data) c.message.Data = strings.TrimSuffix(c.message.Data, "\r\n.\r\n") id, err := c.mongo.Store(c.message) c.state = MAIL if err != nil { - // FIXME - c.Write("500", "Error") + c.log("Error storing message: %s", err) + c.Write("452", "Unable to store message") return } c.Write("250", "Ok: queued as " + id) @@ -96,12 +98,15 @@ func (c *Session) Parse() { func (c *Session) Write(code string, text ...string) { if len(text) == 1 { + c.log("Sent %d bytes: '%s'", len(text[0] + "\n"), text[0] + "\n") c.conn.Write([]byte(code + " " + text[0] + "\n")) return } for i := 0; i < len(text) - 1; i++ { + c.log("Sent %d bytes: '%s'", len(text[i] + "\n"), text[i] + "\n") c.conn.Write([]byte(code + "-" + text[i] + "\n")) } + c.log("Sent %d bytes: '%s'", len(text[len(text)-1] + "\n"), text[len(text)-1] + "\n") c.conn.Write([]byte(code + " " + text[len(text)-1] + "\n")) } @@ -143,6 +148,7 @@ func (c *Session) Process(line string) { c.Write("250", "Hello " + args, "PIPELINING") default: c.log("Got unknown command for ESTABLISH state: '%s'", command) + c.Write("500", "Unrecognised command") } case c.state == MAIL: switch command { @@ -155,6 +161,7 @@ func (c *Session) Process(line string) { c.Write("250", "Sender " + match[1] + " ok") default: c.log("Got unknown command for MAIL state: '%s'", command) + c.Write("500", "Unrecognised command") } case c.state == RCPT: switch command { @@ -171,6 +178,7 @@ func (c *Session) Process(line string) { c.Write("354", "End data with .") default: c.log("Got unknown command for RCPT state: '%s'", command) + c.Write("500", "Unrecognised command") } } } diff --git a/main_test.go b/main_test.go index 78ab117..ed68088 100644 --- a/main_test.go +++ b/main_test.go @@ -31,7 +31,10 @@ func TestBasicHappyPath(t *testing.T) { // Read the response n, err = conn.Read(buf) assert.Nil(t, err) - assert.Equal(t, string(buf[0:n]), "250 Hello localhost\n") + assert.Equal(t, string(buf[0:n]), "250-Hello localhost\n") + n, err = conn.Read(buf) + assert.Nil(t, err) + assert.Equal(t, string(buf[0:n]), "250 PIPELINING\n") // Send MAIL _, err = conn.Write([]byte("MAIL From:\r\n")) diff --git a/mime_test.go b/mime_test.go new file mode 100644 index 0000000..f028e20 --- /dev/null +++ b/mime_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "github.com/ian-kent/MailHog/mailhog" + "github.com/ian-kent/MailHog/mailhog/storage" + "github.com/stretchr/testify/assert" + "net" + "regexp" + "strings" + "testing" +) + +// FIXME requires a running instance of MailHog + +func TestBasicMIMEHappyPath(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") + n, err = conn.Read(buf) + assert.Nil(t, err) + assert.Equal(t, string(buf[0:n]), "250 PIPELINING\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: multipart/alternative; boundary=\"--mailhog-test-boundary\"\r\n" + content += "Content-Length: 220\r\n" + content += "From: Nobody \r\n" + content += "To: Someone \r\n" + content += "Subject: Example message\r\n" + content += "MIME-Version: 1.0\r\n" + content += "\r\n" + content += "--mailhog-test-boundary\r\n" + content += "Content-Type: text/plain\r\n" + content += "\r\n" + content += "Hi there :)\r\n" + content += "--mailhog-test-boundary\r\n" + content += "Content-Type: text/html\r\n" + content += "\r\n" + content += "\r\n" + content += " \r\n" + content += " Example message\r\n" + content += " \r\n" + content += " \r\n" + content += "

\r\n" + content += " Hi there :)\r\n" + content += "

\r\n" + content += " \r\n" + content += "\r\n" + content += "--mailhog-test-boundary\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") + + s := storage.CreateMongoDB(mailhog.DefaultConfig()) + message, err := s.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), 9, "message has 7 headers") + assert.Equal(t, message.Content.Headers["Content-Type"], []string{"multipart/alternative; boundary=\"--mailhog-test-boundary\""}, "Content-Type header is multipart/alternative; boundary=\"--mailhog-test-boundary\"") + assert.Equal(t, message.Content.Headers["Subject"], []string{"Example message"}, "Subject header is Example message") + 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.True(t, strings.HasPrefix(message.Content.Headers["Received"][0], "from localhost by mailhog.example (Go-MailHog)\r\n id "+match[1]+"@mailhog.example; "), "Received header is correct") + 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") + + expected := "--mailhog-test-boundary\r\nContent-Type: text/plain\r\n\r\nHi there :)\r\n--mailhog-test-boundary\r\nContent-Type: text/html\r\n\r\n\r\n \r\n Example message\r\n \r\n \r\n

\r\n Hi there :)\r\n

\r\n \r\n\r\n--mailhog-test-boundary" + assert.Equal(t, message.Content.Body, expected, "message has correct body") + + +}