Refactor SMTP protocol into separate package

This commit is contained in:
Ian Kent 2014-11-22 16:11:04 +00:00
parent cbc85d9647
commit b46525f223
6 changed files with 438 additions and 277 deletions

View file

@ -1593,6 +1593,11 @@ type _bintree_t struct {
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
"assets": &_bintree_t{nil, map[string]*_bintree_t{
"images": &_bintree_t{nil, map[string]*_bintree_t{
"github.png": &_bintree_t{assets_images_github_png, map[string]*_bintree_t{}},
"hog.png": &_bintree_t{assets_images_hog_png, map[string]*_bintree_t{}},
"ajax-loader.gif": &_bintree_t{assets_images_ajax_loader_gif, map[string]*_bintree_t{}},
}},
"js": &_bintree_t{nil, map[string]*_bintree_t{
"controllers.js": &_bintree_t{assets_js_controllers_js, map[string]*_bintree_t{}},
"strutil.js": &_bintree_t{assets_js_strutil_js, map[string]*_bintree_t{}},
@ -1601,10 +1606,5 @@ var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
"index.html": &_bintree_t{assets_templates_index_html, map[string]*_bintree_t{}},
"layout.html": &_bintree_t{assets_templates_layout_html, map[string]*_bintree_t{}},
}},
"images": &_bintree_t{nil, map[string]*_bintree_t{
"ajax-loader.gif": &_bintree_t{assets_images_ajax_loader_gif, map[string]*_bintree_t{}},
"github.png": &_bintree_t{assets_images_github_png, map[string]*_bintree_t{}},
"hog.png": &_bintree_t{assets_images_hog_png, map[string]*_bintree_t{}},
}},
}},
}}

View file

@ -0,0 +1,318 @@
package smtp
// http://www.rfc-editor.org/rfc/rfc5321.txt
import (
"errors"
"log"
"regexp"
"strings"
"github.com/ian-kent/Go-MailHog/mailhog/config"
"github.com/ian-kent/Go-MailHog/mailhog/data"
)
// Protocol is a state machine representing an SMTP session
type Protocol struct {
conf *config.Config
state State
message *data.SMTPMessage
LogHandler func(message string, args ...interface{})
MessageReceivedHandler func(*data.Message) (string, error)
}
// Command is a struct representing an SMTP command (verb + arguments)
type Command struct {
verb string
args string
}
// Reply is a struct representing an SMTP reply (status code + lines)
type Reply struct {
status int
lines []string
}
// ReplyOk creates a 250 Ok reply
func ReplyOk() *Reply { return &Reply{250, []string{"Ok"}} }
// ReplyBye creates a 221 Bye reply
func ReplyBye() *Reply { return &Reply{221, []string{"Bye"}} }
// ReplyUnrecognisedCommand creates a 500 Unrecognised command reply
func ReplyUnrecognisedCommand() *Reply { return &Reply{500, []string{"Unrecognised command"}} }
// ReplySenderOk creates a 250 Sender ok reply
func ReplySenderOk(sender string) *Reply { return &Reply{250, []string{"Sender " + sender + " ok"}} }
// ReplyRecipientOk creates a 250 Sender ok reply
func ReplyRecipientOk(recipient string) *Reply {
return &Reply{250, []string{"Recipient " + recipient + " ok"}}
}
// ReplyError creates a 500 error reply
func ReplyError(err error) *Reply { return &Reply{550, []string{err.Error()}} }
// State represents the state of an SMTP conversation
type State int
// SMTP message conversation states
const (
INVALID = State(-1)
ESTABLISH = State(iota)
AUTH
AUTH2
MAIL
RCPT
DATA
DONE
)
// StateMap provides string representations of SMTP conversation states
var StateMap = map[State]string{
INVALID: "INVALID",
ESTABLISH: "ESTABLISH",
AUTH: "AUTH",
AUTH2: "AUTH2",
MAIL: "MAIL",
RCPT: "RCPT",
DATA: "DATA",
DONE: "DONE",
}
// NewProtocol returns a new SMTP state machine in INVALID state
// handler is called when a message is received and should return a message ID
func NewProtocol(cfg *config.Config) *Protocol {
return &Protocol{
conf: cfg,
state: INVALID,
message: &data.SMTPMessage{},
}
}
// TODO replace ".." lines with . in data
func (proto *Protocol) logf(message string, args ...interface{}) {
message = strings.Join([]string{"[PROTO: %s]", message}, " ")
args = append([]interface{}{StateMap[proto.state]}, args...)
if proto.LogHandler != nil {
proto.LogHandler(message, args...)
} else {
log.Printf(message, args...)
}
}
// Start begins an SMTP conversation with a 220 reply
func (proto *Protocol) Start() *Reply {
proto.state = ESTABLISH
return &Reply{
status: 220,
lines: []string{proto.conf.Hostname + " ESMTP Go-MailHog"},
}
}
// Parse parses a line string and returns any remaining line string
// and a reply, if a command was found. Parse does nothing until a
// new line is found.
// - TODO move this to a buffer inside proto?
func (proto *Protocol) Parse(line string) (string, *Reply) {
var reply *Reply
for strings.Contains(line, "\n") {
parts := strings.SplitN(line, "\n", 2)
if len(parts) == 2 {
line = parts[1]
} else {
line = ""
}
if proto.state == DATA {
return line, proto.ProcessData(parts[0])
}
return line, proto.ProcessCommand(parts[0])
}
return line, reply
}
// ProcessData handles content received (with newlines stripped) while
// in the SMTP DATA state
func (proto *Protocol) ProcessData(line string) (reply *Reply) {
proto.message.Data += line + "\n"
if strings.HasSuffix(proto.message.Data, "\r\n.\r\n") {
proto.logf("Got EOF, storing message and switching to MAIL state")
proto.message.Data = strings.TrimSuffix(proto.message.Data, "\r\n.\r\n")
proto.state = MAIL
msg := data.ParseSMTPMessage(proto.message, proto.conf.Hostname)
if proto.MessageReceivedHandler != nil {
id, err := proto.MessageReceivedHandler(msg)
if err != nil {
proto.logf("Error storing message: %s", err)
reply = &Reply{452, []string{"Unable to store message"}}
} else {
reply = &Reply{250, []string{"Ok: queued as " + id}}
}
} else {
reply = &Reply{452, []string{"No storage backend"}}
}
}
return
}
// ProcessCommand processes a line of text as a command
// It expects the line string to be a properly formed SMTP verb and arguments
func (proto *Protocol) ProcessCommand(line string) (reply *Reply) {
line = strings.Trim(line, "\r\n")
proto.logf("Processing line: %s", line)
words := strings.Split(line, " ")
command := strings.ToUpper(words[0])
args := strings.Join(words[1:len(words)], " ")
proto.logf("In state %d, got command '%s', args '%s'", proto.state, command, args)
cmd := &Command{command, args}
return proto.Command(cmd)
}
// Command applies an SMTP verb and arguments to the state machine
func (proto *Protocol) Command(command *Command) (reply *Reply) {
switch {
case "RSET" == command.verb:
proto.logf("Got RSET command, switching to MAIL state")
proto.state = MAIL
proto.message = &data.SMTPMessage{}
return ReplyOk()
case "NOOP" == command.verb:
proto.logf("Got NOOP verb, staying in %s state", StateMap[proto.state])
return ReplyOk()
case "QUIT" == command.verb:
proto.logf("Got QUIT verb, staying in %s state", StateMap[proto.state])
return ReplyBye()
case ESTABLISH == proto.state:
switch command.verb {
case "HELO":
return proto.HELO(command.args)
case "EHLO":
return proto.EHLO(command.args)
default:
proto.logf("Got unknown command for ESTABLISH state: '%s'", command.verb)
return ReplyUnrecognisedCommand()
}
case AUTH == proto.state:
proto.logf("Got authentication response: '%s', switching to MAIL state", command.args)
proto.state = MAIL
return &Reply{235, []string{"Authentication successful"}}
case AUTH2 == proto.state: // TODO rename AUTH2 state...
proto.logf("Got LOGIN authentication response: '%s', switching to AUTH state", command.args)
proto.state = AUTH
return &Reply{334, []string{"UGFzc3dvcmQ6"}}
case MAIL == proto.state: // TODO rename/split state
switch command.verb {
case "AUTH":
proto.logf("Got AUTH command, staying in MAIL state")
switch {
case strings.HasPrefix(command.args, "PLAIN "):
proto.logf("Got PLAIN authentication: %s", strings.TrimPrefix(command.args, "PLAIN "))
return &Reply{235, []string{"Authentication successful"}}
case "LOGIN" == command.args:
proto.logf("Got LOGIN authentication, switching to AUTH state")
proto.state = AUTH2
return &Reply{334, []string{"VXNlcm5hbWU6"}}
case "PLAIN" == command.args:
proto.logf("Got PLAIN authentication (no args), switching to AUTH2 state")
proto.state = AUTH
return &Reply{334, []string{}}
case "CRAM-MD5" == command.args:
proto.logf("Got CRAM-MD5 authentication, switching to AUTH state")
proto.state = AUTH
return &Reply{334, []string{"PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4="}}
case strings.HasPrefix(command.args, "EXTERNAL "):
proto.logf("Got EXTERNAL authentication: %s", strings.TrimPrefix(command.args, "EXTERNAL "))
return &Reply{235, []string{"Authentication successful"}}
default:
return &Reply{504, []string{"Unsupported authentication mechanism"}}
}
case "MAIL":
proto.logf("Got MAIL command, switching to RCPT state")
from, err := ParseMAIL(command.args)
if err != nil {
return ReplyError(err)
}
proto.message.From = from
proto.state = RCPT
return ReplySenderOk(from)
case "HELO": // TODO feels hacky
return proto.HELO(command.args)
case "EHLO": // TODO feels hacky?
return proto.EHLO(command.args)
default:
proto.logf("Got unknown command for MAIL state: '%s'", command)
return ReplyUnrecognisedCommand()
}
case RCPT == proto.state:
switch command.verb {
case "RCPT":
proto.logf("Got RCPT command")
to, err := ParseRCPT(command.args)
if err != nil {
return ReplyError(err)
}
proto.message.To = append(proto.message.To, to)
proto.state = RCPT
return ReplyRecipientOk(to)
case "DATA":
proto.logf("Got DATA command, switching to DATA state")
proto.state = DATA
return &Reply{354, []string{"End data with <CR><LF>.<CR><LF>"}}
default:
proto.logf("Got unknown command for RCPT state: '%s'", command)
return ReplyUnrecognisedCommand()
}
default:
return ReplyUnrecognisedCommand()
}
}
// HELO creates a reply to a HELO command
func (proto *Protocol) HELO(args string) (reply *Reply) {
proto.logf("Got HELO command, switching to MAIL state")
proto.state = MAIL
proto.message.Helo = args
return &Reply{250, []string{"Hello " + args}}
}
// EHLO creates a reply to a EHLO command
func (proto *Protocol) EHLO(args string) (reply *Reply) {
proto.logf("Got EHLO command, switching to MAIL state")
proto.state = MAIL
proto.message.Helo = args
return &Reply{250, []string{"Hello " + args, "PIPELINING", "AUTH EXTERNAL CRAM-MD5 LOGIN PLAIN"}}
}
// ParseMAIL returns the forward-path from a MAIL command argument
func ParseMAIL(mail string) (string, error) {
r := regexp.MustCompile("(?i:From):<([^>]+)>")
match := r.FindStringSubmatch(mail)
if len(match) != 2 {
return "", errors.New("Invalid sender")
}
return match[1], nil
}
// ParseRCPT returns the return-path from a RCPT command argument
func ParseRCPT(rcpt string) (string, error) {
r := regexp.MustCompile("(?i:To):<([^>]+)>")
match := r.FindStringSubmatch(rcpt)
if len(match) != 2 {
return "", errors.New("Invalid recipient")
}
return match[1], nil
}

View file

@ -0,0 +1,113 @@
package smtp
// http://www.rfc-editor.org/rfc/rfc5321.txt
import (
"errors"
"log"
"net"
"strconv"
"strings"
"github.com/ian-kent/Go-MailHog/mailhog/config"
"github.com/ian-kent/Go-MailHog/mailhog/data"
"github.com/ian-kent/Go-MailHog/mailhog/storage"
)
// Session represents a SMTP session using net.TCPConn
type Session struct {
conn *net.TCPConn
proto *Protocol
conf *config.Config
isTLS bool
line string
}
// Accept starts a new SMTP session using net.TCPConn
func Accept(conn *net.TCPConn, conf *config.Config) {
proto := NewProtocol(conf)
session := &Session{conn, proto, conf, false, ""}
proto.LogHandler = session.logf
proto.MessageReceivedHandler = session.acceptMessageHandler
session.logf("Starting session")
session.Write(proto.Start())
for session.Read() == true {
}
session.logf("Session ended")
}
func (c *Session) acceptMessageHandler(msg *data.Message) (id string, err error) {
switch c.conf.Storage.(type) {
case *storage.MongoDB:
c.logf("Storing message using MongoDB")
id, err = c.conf.Storage.(*storage.MongoDB).Store(msg)
case *storage.Memory:
c.logf("Storing message using Memory")
id, err = c.conf.Storage.(*storage.Memory).Store(msg)
default:
err = errors.New("Unknown storage stype")
}
return
}
func (c *Session) logf(message string, args ...interface{}) {
message = strings.Join([]string{"[SMTP %s]", message}, " ")
args = append([]interface{}{c.conn.RemoteAddr()}, args...)
log.Printf(message, args...)
}
// Read reads from the underlying net.TCPConn
func (c *Session) Read() bool {
buf := make([]byte, 1024)
n, err := c.conn.Read(buf)
if n == 0 {
c.logf("Connection closed by remote host\n")
return false
}
if err != nil {
c.logf("Error reading from socket: %s\n", err)
return false
}
text := string(buf[0:n])
logText := strings.Replace(text, "\n", "\\n", -1)
logText = strings.Replace(logText, "\r", "\\r", -1)
c.logf("Received %d bytes: '%s'\n", n, logText)
c.line += text
line, reply := c.proto.Parse(c.line)
c.line = line
if reply != nil {
c.Write(reply)
if reply.status == 221 {
c.conn.Close()
}
}
return true
}
// Write writes a reply to the underlying net.TCPConn
func (c *Session) Write(reply *Reply) {
if len(reply.lines) == 0 {
l := strconv.Itoa(reply.status)
c.logf("Sent %d bytes: '%s'", len(l), l)
c.conn.Write([]byte(l))
}
for i, line := range reply.lines {
l := ""
if i == len(reply.lines)-1 {
l = strconv.Itoa(reply.status) + " " + line + "\n"
} else {
l = strconv.Itoa(reply.status) + "-" + line + "\n"
}
logText := strings.Replace(l, "\n", "\\n", -1)
logText = strings.Replace(logText, "\r", "\\r", -1)
c.logf("Sent %d bytes: '%s'", len(l), logText)
c.conn.Write([]byte(l))
}
}

View file

@ -1,270 +0,0 @@
package smtp
// http://www.rfc-editor.org/rfc/rfc5321.txt
import (
"errors"
"log"
"net"
"regexp"
"strings"
"github.com/ian-kent/Go-MailHog/mailhog/config"
"github.com/ian-kent/Go-MailHog/mailhog/data"
"github.com/ian-kent/Go-MailHog/mailhog/storage"
)
type Session struct {
conn *net.TCPConn
line string
conf *config.Config
state int
message *data.SMTPMessage
isTLS bool
}
const (
ESTABLISH = iota
AUTH
AUTH2
MAIL
RCPT
DATA
DONE
)
// TODO replace ".." lines with . in data
func StartSession(conn *net.TCPConn, conf *config.Config) {
conv := &Session{conn, "", conf, ESTABLISH, &data.SMTPMessage{}, false}
conv.log("Starting session")
conv.Write("220", conv.conf.Hostname+" ESMTP Go-MailHog")
conv.Read()
}
func (c *Session) log(message string, args ...interface{}) {
message = strings.Join([]string{"[SMTP %s, %d]", message}, " ")
args = append([]interface{}{c.conn.RemoteAddr(), c.state}, args...)
log.Printf(message, args...)
}
func (c *Session) Read() {
buf := make([]byte, 1024)
n, err := c.conn.Read(buf)
if n == 0 {
c.log("Connection closed by remote host\n")
return
}
if err != nil {
c.log("Error reading from socket: %s\n", err)
return
}
text := string(buf[0:n])
c.log("Received %d bytes: '%s'\n", n, text)
c.line += text
c.Parse()
}
func (c *Session) Parse() {
for strings.Contains(c.line, "\n") {
parts := strings.SplitN(c.line, "\n", 2)
if len(parts) == 2 {
c.line = parts[1]
} else {
c.line = ""
}
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")
msg := data.ParseSMTPMessage(c.message, c.conf.Hostname)
var id string
var err error
switch c.conf.Storage.(type) {
case *storage.MongoDB:
c.log("Storing message using MongoDB")
id, err = c.conf.Storage.(*storage.MongoDB).Store(msg)
case *storage.Memory:
c.log("Storing message using Memory")
id, err = c.conf.Storage.(*storage.Memory).Store(msg)
default:
c.log("Unknown storage type")
// TODO send error reply
}
c.state = MAIL
if err != nil {
c.log("Error storing message: %s", err)
c.Write("452", "Unable to store message")
return
}
c.Write("250", "Ok: queued as "+id)
c.conf.MessageChan <- msg
}
} else {
c.Process(strings.Trim(parts[0], "\r\n"))
}
}
c.Read()
}
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"))
}
func (c *Session) Process(line string) {
c.log("Processing line: %s", line)
words := strings.Split(line, " ")
command := strings.ToUpper(words[0])
args := strings.Join(words[1:len(words)], " ")
c.log("In state %d, got command '%s', args '%s'", c.state, command, args)
switch {
case command == "RSET":
c.log("Got RSET command, switching to MAIL state")
c.state = MAIL
c.message = &data.SMTPMessage{}
c.Write("250", "Ok")
case command == "NOOP":
c.log("Got NOOP command")
c.Write("250", "Ok")
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":
c.DoHELO(args)
case "EHLO":
c.DoEHLO(args)
default:
c.log("Got unknown command for ESTABLISH state: '%s'", command)
c.Write("500", "Unrecognised command")
}
case c.state == AUTH:
c.log("Got authentication response: '%s', switching to MAIL state", args)
c.state = MAIL
c.Write("235", "Authentication successful")
case c.state == AUTH2:
c.log("Got LOGIN authentication response: '%s', switching to AUTH state", args)
c.state = AUTH
c.Write("334", "UGFzc3dvcmQ6")
case c.state == MAIL: // TODO rename/split state
switch command {
case "AUTH":
c.log("Got AUTH command, staying in MAIL state")
switch {
case strings.HasPrefix(args, "PLAIN "):
c.log("Got PLAIN authentication: %s", strings.TrimPrefix(args, "PLAIN "))
c.Write("235", "Authentication successful")
case args == "LOGIN":
c.log("Got LOGIN authentication, switching to AUTH state")
c.state = AUTH2
c.Write("334", "VXNlcm5hbWU6")
case args == "PLAIN":
c.log("Got PLAIN authentication (no args), switching to AUTH2 state")
c.state = AUTH
c.Write("334", "")
case args == "CRAM-MD5":
c.log("Got CRAM-MD5 authentication, switching to AUTH state")
c.state = AUTH
c.Write("334", "PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=")
case strings.HasPrefix(args, "EXTERNAL "):
c.log("Got EXTERNAL authentication: %s", strings.TrimPrefix(args, "EXTERNAL "))
c.Write("235", "Authentication successful")
default:
c.Write("504", "Unsupported authentication mechanism")
}
case "MAIL":
c.log("Got MAIL command, switching to RCPT state")
from, err := ParseMAIL(args)
if err != nil {
c.Write("550", err.Error())
return
}
c.message.From = from
c.state = RCPT
c.Write("250", "Sender "+from+" ok")
case "HELO":
c.DoHELO(args)
case "EHLO":
c.DoEHLO(args)
default:
c.log("Got unknown command for MAIL state: '%s'", command)
c.Write("500", "Unrecognised command")
}
case c.state == RCPT:
switch command {
case "RCPT":
c.log("Got RCPT command")
to, err := ParseRCPT(args)
if err != nil {
c.Write("550", err.Error())
return
}
c.message.To = append(c.message.To, to)
c.state = RCPT
c.Write("250", "Recipient "+to+" ok")
case "DATA":
c.log("Got DATA command, switching to DATA state")
c.state = DATA
c.Write("354", "End data with <CR><LF>.<CR><LF>")
default:
c.log("Got unknown command for RCPT state: '%s'", command)
c.Write("500", "Unrecognised command")
}
}
}
func (c *Session) DoHELO(args string) {
c.log("Got HELO command, switching to MAIL state")
c.state = MAIL
c.message.Helo = args
c.Write("250", "Hello "+args)
}
func (c *Session) DoEHLO(args string) {
c.log("Got EHLO command, switching to MAIL state")
c.state = MAIL
c.message.Helo = args
c.Write("250", "Hello "+args, "PIPELINING", "AUTH EXTERNAL CRAM-MD5 LOGIN PLAIN")
}
func ParseMAIL(mail string) (string, error) {
r := regexp.MustCompile("(?i:From):<([^>]+)>")
match := r.FindStringSubmatch(mail)
if len(match) != 2 {
return "", errors.New("Invalid sender")
}
return match[1], nil
}
func ParseRCPT(rcpt string) (string, error) {
r := regexp.MustCompile("(?i:To):<([^>]+)>")
match := r.FindStringSubmatch(rcpt)
if len(match) != 2 {
return "", errors.New("Invalid recipient")
}
return match[1], nil
}

View file

@ -8,7 +8,7 @@ import (
"github.com/ian-kent/Go-MailHog/mailhog/config"
mhhttp "github.com/ian-kent/Go-MailHog/mailhog/http"
"github.com/ian-kent/Go-MailHog/mailhog/http/api"
"github.com/ian-kent/Go-MailHog/mailhog/smtp"
smtp "github.com/ian-kent/Go-MailHog/mailhog/smtp/server"
"github.com/ian-kent/Go-MailHog/mailhog/storage"
"github.com/ian-kent/envconf"
"github.com/ian-kent/go-log/log"
@ -122,6 +122,6 @@ func smtp_listen() *net.TCPListener {
}
defer conn.Close()
go smtp.StartSession(conn.(*net.TCPConn), conf)
go smtp.Accept(conn.(*net.TCPConn), conf)
}
}