mirror of
https://gitlab.com/ric_harvey/MailHog.git
synced 2024-11-24 23:04:03 +00:00
504 lines
16 KiB
Go
504 lines
16 KiB
Go
package smtp
|
|
|
|
// http://www.rfc-editor.org/rfc/rfc5321.txt
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"log"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/mailhog/data"
|
|
)
|
|
|
|
// Command is a struct representing an SMTP command (verb + arguments)
|
|
type Command struct {
|
|
verb string
|
|
args string
|
|
orig string
|
|
}
|
|
|
|
// ParseCommand returns a Command from the line string
|
|
func ParseCommand(line string) *Command {
|
|
words := strings.Split(line, " ")
|
|
command := strings.ToUpper(words[0])
|
|
args := strings.Join(words[1:len(words)], " ")
|
|
|
|
return &Command{
|
|
verb: command,
|
|
args: args,
|
|
orig: line,
|
|
}
|
|
}
|
|
|
|
// Protocol is a state machine representing an SMTP session
|
|
type Protocol struct {
|
|
lastCommand *Command
|
|
|
|
TLSPending bool
|
|
TLSUpgraded bool
|
|
|
|
State State
|
|
Message *data.SMTPMessage
|
|
|
|
Hostname string
|
|
Ident string
|
|
|
|
MaximumLineLength int
|
|
MaximumRecipients int
|
|
|
|
// LogHandler is called for each log message. If nil, log messages will
|
|
// be output using log.Printf instead.
|
|
LogHandler func(message string, args ...interface{})
|
|
// MessageReceivedHandler is called for each message accepted by the
|
|
// SMTP protocol. It must return a MessageID or error. If nil, messages
|
|
// will be rejected with an error.
|
|
MessageReceivedHandler func(*data.SMTPMessage) (string, error)
|
|
// ValidateSenderHandler should return true if the sender is valid,
|
|
// otherwise false. If nil, all senders will be accepted.
|
|
ValidateSenderHandler func(from string) bool
|
|
// ValidateRecipientHandler should return true if the recipient is valid,
|
|
// otherwise false. If nil, all recipients will be accepted.
|
|
ValidateRecipientHandler func(to string) bool
|
|
// ValidateAuthenticationhandler should return true if the authentication
|
|
// parameters are valid, otherwise false. If nil, all authentication
|
|
// attempts will be accepted.
|
|
ValidateAuthenticationHandler func(mechanism string, args ...string) (errorReply *Reply, ok bool)
|
|
// SMTPVerbFilter is called after each command is parsed, but before
|
|
// any code is executed. This provides an opportunity to reject unwanted verbs,
|
|
// e.g. to require AUTH before MAIL
|
|
SMTPVerbFilter func(verb string, args ...string) (errorReply *Reply)
|
|
// TLSHandler is called when a STARTTLS command is received.
|
|
//
|
|
// It should acknowledge the TLS request and set ok to true.
|
|
// It should also return a callback which will be invoked after the reply is
|
|
// sent. E.g., a TCP connection can only perform the upgrade after sending the reply
|
|
//
|
|
// Once the upgrade is complete, invoke the done function (e.g., from the returned callback)
|
|
//
|
|
// If TLS upgrade isn't possible, return an errorReply and set ok to false.
|
|
TLSHandler func(done func(ok bool)) (errorReply *Reply, callback func(), ok bool)
|
|
|
|
// GetAuthenticationMechanismsHandler should return an array of strings
|
|
// listing accepted authentication mechanisms
|
|
GetAuthenticationMechanismsHandler func() []string
|
|
|
|
// RejectBrokenRCPTSyntax controls whether the protocol accepts technically
|
|
// invalid syntax for the RCPT command. Set to true, the RCPT syntax requires
|
|
// no space between `TO:` and the opening `<`
|
|
RejectBrokenRCPTSyntax bool
|
|
// RejectBrokenMAILSyntax controls whether the protocol accepts technically
|
|
// invalid syntax for the MAIL command. Set to true, the MAIL syntax requires
|
|
// no space between `FROM:` and the opening `<`
|
|
RejectBrokenMAILSyntax bool
|
|
// RequireTLS controls whether TLS is required for a connection before other
|
|
// commands can be issued, applied at the protocol layer.
|
|
RequireTLS bool
|
|
}
|
|
|
|
// 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() *Protocol {
|
|
p := &Protocol{
|
|
Hostname: "mailhog.example",
|
|
Ident: "ESMTP MailHog",
|
|
State: INVALID,
|
|
MaximumLineLength: -1,
|
|
MaximumRecipients: -1,
|
|
}
|
|
p.resetState()
|
|
return p
|
|
}
|
|
|
|
func (proto *Protocol) resetState() {
|
|
proto.Message = &data.SMTPMessage{}
|
|
}
|
|
|
|
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, placing the state
|
|
// machine in ESTABLISH state.
|
|
func (proto *Protocol) Start() *Reply {
|
|
proto.logf("Started session, switching to ESTABLISH state")
|
|
proto.State = ESTABLISH
|
|
return ReplyIdent(proto.Hostname + " " + proto.Ident)
|
|
}
|
|
|
|
// 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 decide whether to move this to a buffer inside Protocol
|
|
// sort of like it this way, since it gives control back to the caller
|
|
func (proto *Protocol) Parse(line string) (string, *Reply) {
|
|
var reply *Reply
|
|
|
|
if !strings.Contains(line, "\r\n") {
|
|
return line, reply
|
|
}
|
|
|
|
parts := strings.SplitN(line, "\r\n", 2)
|
|
line = parts[1]
|
|
|
|
if proto.MaximumLineLength > -1 {
|
|
if len(parts[0]) > proto.MaximumLineLength {
|
|
return line, ReplyLineTooLong()
|
|
}
|
|
}
|
|
|
|
// TODO collapse AUTH states into separate processing
|
|
if proto.State == DATA {
|
|
reply = proto.ProcessData(parts[0])
|
|
} else {
|
|
reply = 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 + "\r\n"
|
|
|
|
if strings.HasSuffix(proto.Message.Data, "\r\n.\r\n") {
|
|
proto.Message.Data = strings.Replace(proto.Message.Data, "\r\n..", "\r\n.", -1)
|
|
|
|
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
|
|
|
|
defer proto.resetState()
|
|
|
|
if proto.MessageReceivedHandler == nil {
|
|
return ReplyStorageFailed("No storage backend")
|
|
}
|
|
|
|
id, err := proto.MessageReceivedHandler(proto.Message)
|
|
if err != nil {
|
|
proto.logf("Error storing message: %s", err)
|
|
return ReplyStorageFailed("Unable to store message")
|
|
}
|
|
return ReplyOk("Ok: queued as " + id)
|
|
}
|
|
|
|
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 := ParseCommand(strings.TrimSuffix(line, "\r\n"))
|
|
return proto.Command(cmd)
|
|
}
|
|
|
|
// Command applies an SMTP verb and arguments to the state machine
|
|
func (proto *Protocol) Command(command *Command) (reply *Reply) {
|
|
defer func() {
|
|
proto.lastCommand = command
|
|
}()
|
|
if proto.SMTPVerbFilter != nil {
|
|
proto.logf("sending to SMTP verb filter")
|
|
r := proto.SMTPVerbFilter(command.verb)
|
|
if r != nil {
|
|
proto.logf("response returned by SMTP verb filter")
|
|
return r
|
|
}
|
|
}
|
|
switch {
|
|
case proto.TLSPending && !proto.TLSUpgraded:
|
|
proto.logf("Got command before TLS upgrade complete")
|
|
// FIXME what to do?
|
|
return ReplyBye()
|
|
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])
|
|
proto.State = DONE
|
|
return ReplyBye()
|
|
case ESTABLISH == proto.State:
|
|
proto.logf("In ESTABLISH state")
|
|
switch command.verb {
|
|
case "HELO":
|
|
return proto.HELO(command.args)
|
|
case "EHLO":
|
|
return proto.EHLO(command.args)
|
|
case "STARTTLS":
|
|
return proto.STARTTLS(command.args)
|
|
default:
|
|
proto.logf("Got unknown command for ESTABLISH state: '%s'", command.verb)
|
|
return ReplyUnrecognisedCommand()
|
|
}
|
|
case "STARTTLS" == command.verb:
|
|
proto.logf("Got STARTTLS command outside ESTABLISH state")
|
|
return proto.STARTTLS(command.args)
|
|
case proto.RequireTLS && !proto.TLSUpgraded:
|
|
proto.logf("RequireTLS set and not TLS not upgraded")
|
|
return ReplyMustIssueSTARTTLSFirst()
|
|
case AUTHPLAIN == proto.State:
|
|
proto.logf("Got PLAIN authentication response: '%s', switching to MAIL state", command.args)
|
|
proto.State = MAIL
|
|
if proto.ValidateAuthenticationHandler != nil {
|
|
// TODO error handling
|
|
val, _ := base64.StdEncoding.DecodeString(command.orig)
|
|
bits := strings.Split(string(val), string(rune(0)))
|
|
|
|
if len(bits) < 3 {
|
|
return ReplyError(errors.New("Badly formed parameter"))
|
|
}
|
|
|
|
user, pass := bits[1], bits[2]
|
|
|
|
if reply, ok := proto.ValidateAuthenticationHandler("PLAIN", user, pass); !ok {
|
|
return reply
|
|
}
|
|
}
|
|
return ReplyAuthOk()
|
|
case AUTHLOGIN == proto.State:
|
|
proto.logf("Got LOGIN authentication response: '%s', switching to AUTHLOGIN2 state", command.args)
|
|
proto.State = AUTHLOGIN2
|
|
return ReplyAuthResponse("UGFzc3dvcmQ6")
|
|
case AUTHLOGIN2 == proto.State:
|
|
proto.logf("Got LOGIN authentication response: '%s', switching to MAIL state", command.args)
|
|
proto.State = MAIL
|
|
if proto.ValidateAuthenticationHandler != nil {
|
|
if reply, ok := proto.ValidateAuthenticationHandler("LOGIN", proto.lastCommand.orig, command.orig); !ok {
|
|
return reply
|
|
}
|
|
}
|
|
return ReplyAuthOk()
|
|
case AUTHCRAMMD5 == proto.State:
|
|
proto.logf("Got CRAM-MD5 authentication response: '%s', switching to MAIL state", command.args)
|
|
proto.State = MAIL
|
|
if proto.ValidateAuthenticationHandler != nil {
|
|
if reply, ok := proto.ValidateAuthenticationHandler("CRAM-MD5", command.orig); !ok {
|
|
return reply
|
|
}
|
|
}
|
|
return ReplyAuthOk()
|
|
case MAIL == proto.State:
|
|
proto.logf("In MAIL 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 "))
|
|
if proto.ValidateAuthenticationHandler != nil {
|
|
val, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(command.args, "PLAIN "))
|
|
bits := strings.Split(string(val), string(rune(0)))
|
|
|
|
if len(bits) < 3 {
|
|
return ReplyError(errors.New("Badly formed parameter"))
|
|
}
|
|
|
|
user, pass := bits[1], bits[2]
|
|
|
|
if reply, ok := proto.ValidateAuthenticationHandler("PLAIN", user, pass); !ok {
|
|
return reply
|
|
}
|
|
}
|
|
return ReplyAuthOk()
|
|
case "LOGIN" == command.args:
|
|
proto.logf("Got LOGIN authentication, switching to AUTH state")
|
|
proto.State = AUTHLOGIN
|
|
return ReplyAuthResponse("VXNlcm5hbWU6")
|
|
case "PLAIN" == command.args:
|
|
proto.logf("Got PLAIN authentication (no args), switching to AUTH2 state")
|
|
proto.State = AUTHPLAIN
|
|
return ReplyAuthResponse("")
|
|
case "CRAM-MD5" == command.args:
|
|
proto.logf("Got CRAM-MD5 authentication, switching to AUTH state")
|
|
proto.State = AUTHCRAMMD5
|
|
return ReplyAuthResponse("PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=")
|
|
case strings.HasPrefix(command.args, "EXTERNAL "):
|
|
proto.logf("Got EXTERNAL authentication: %s", strings.TrimPrefix(command.args, "EXTERNAL "))
|
|
if proto.ValidateAuthenticationHandler != nil {
|
|
if reply, ok := proto.ValidateAuthenticationHandler("EXTERNAL", strings.TrimPrefix(command.args, "EXTERNAL ")); !ok {
|
|
return reply
|
|
}
|
|
}
|
|
return ReplyAuthOk()
|
|
default:
|
|
return ReplyUnsupportedAuth()
|
|
}
|
|
case "MAIL":
|
|
proto.logf("Got MAIL command, switching to RCPT state")
|
|
from, err := proto.ParseMAIL(command.args)
|
|
if err != nil {
|
|
return ReplyError(err)
|
|
}
|
|
if proto.ValidateSenderHandler != nil {
|
|
if !proto.ValidateSenderHandler(from) {
|
|
// TODO correct sender error response
|
|
return ReplyError(errors.New("Invalid sender " + from))
|
|
}
|
|
}
|
|
proto.Message.From = from
|
|
proto.State = RCPT
|
|
return ReplySenderOk(from)
|
|
case "HELO":
|
|
return proto.HELO(command.args)
|
|
case "EHLO":
|
|
return proto.EHLO(command.args)
|
|
default:
|
|
proto.logf("Got unknown command for MAIL state: '%s'", command)
|
|
return ReplyUnrecognisedCommand()
|
|
}
|
|
case RCPT == proto.State:
|
|
proto.logf("In RCPT state")
|
|
switch command.verb {
|
|
case "RCPT":
|
|
proto.logf("Got RCPT command")
|
|
if proto.MaximumRecipients > -1 && len(proto.Message.To) >= proto.MaximumRecipients {
|
|
return ReplyTooManyRecipients()
|
|
}
|
|
to, err := proto.ParseRCPT(command.args)
|
|
if err != nil {
|
|
return ReplyError(err)
|
|
}
|
|
if proto.ValidateRecipientHandler != nil {
|
|
if !proto.ValidateRecipientHandler(to) {
|
|
// TODO correct send error response
|
|
return ReplyError(errors.New("Invalid recipient " + to))
|
|
}
|
|
}
|
|
proto.Message.To = append(proto.Message.To, to)
|
|
proto.State = RCPT
|
|
return ReplyRecipientOk(to)
|
|
case "HELO":
|
|
return proto.HELO(command.args)
|
|
case "EHLO":
|
|
return proto.EHLO(command.args)
|
|
case "DATA":
|
|
proto.logf("Got DATA command, switching to DATA state")
|
|
proto.State = DATA
|
|
return ReplyDataResponse()
|
|
default:
|
|
proto.logf("Got unknown command for RCPT state: '%s'", command)
|
|
return ReplyUnrecognisedCommand()
|
|
}
|
|
default:
|
|
proto.logf("Command not recognised")
|
|
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 ReplyOk("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
|
|
replyArgs := []string{"Hello " + args, "PIPELINING"}
|
|
|
|
if proto.TLSHandler != nil && !proto.TLSPending && !proto.TLSUpgraded {
|
|
replyArgs = append(replyArgs, "STARTTLS")
|
|
}
|
|
|
|
if !proto.RequireTLS || proto.TLSUpgraded {
|
|
if proto.GetAuthenticationMechanismsHandler != nil {
|
|
mechanisms := proto.GetAuthenticationMechanismsHandler()
|
|
if len(mechanisms) > 0 {
|
|
replyArgs = append(replyArgs, "AUTH "+strings.Join(mechanisms, " "))
|
|
}
|
|
}
|
|
}
|
|
return ReplyOk(replyArgs...)
|
|
}
|
|
|
|
// STARTTLS creates a reply to a STARTTLS command
|
|
func (proto *Protocol) STARTTLS(args string) (reply *Reply) {
|
|
if proto.TLSUpgraded {
|
|
return ReplyUnrecognisedCommand()
|
|
}
|
|
|
|
if proto.TLSHandler == nil {
|
|
proto.logf("tls handler not found")
|
|
return ReplyUnrecognisedCommand()
|
|
}
|
|
|
|
if len(args) > 0 {
|
|
return ReplySyntaxError("no parameters allowed")
|
|
}
|
|
|
|
r, callback, ok := proto.TLSHandler(func(ok bool) {
|
|
proto.TLSUpgraded = ok
|
|
proto.TLSPending = ok
|
|
if ok {
|
|
proto.resetState()
|
|
proto.State = ESTABLISH
|
|
}
|
|
})
|
|
if !ok {
|
|
return r
|
|
}
|
|
|
|
proto.TLSPending = true
|
|
return ReplyReadyToStartTLS(callback)
|
|
}
|
|
|
|
var parseMailBrokenRegexp = regexp.MustCompile("(?i:From):\\s*<([^>]+)>")
|
|
var parseMailRFCRegexp = regexp.MustCompile("(?i:From):<([^>]+)>")
|
|
|
|
// ParseMAIL returns the forward-path from a MAIL command argument
|
|
func (proto *Protocol) ParseMAIL(mail string) (string, error) {
|
|
var match []string
|
|
if proto.RejectBrokenMAILSyntax {
|
|
match = parseMailRFCRegexp.FindStringSubmatch(mail)
|
|
} else {
|
|
match = parseMailBrokenRegexp.FindStringSubmatch(mail)
|
|
}
|
|
|
|
if len(match) != 2 {
|
|
return "", errors.New("Invalid syntax in MAIL command")
|
|
}
|
|
return match[1], nil
|
|
}
|
|
|
|
var parseRcptBrokenRegexp = regexp.MustCompile("(?i:To):\\s*<([^>]+)>")
|
|
var parseRcptRFCRegexp = regexp.MustCompile("(?i:To):<([^>]+)>")
|
|
|
|
// ParseRCPT returns the return-path from a RCPT command argument
|
|
func (proto *Protocol) ParseRCPT(rcpt string) (string, error) {
|
|
var match []string
|
|
if proto.RejectBrokenRCPTSyntax {
|
|
match = parseRcptRFCRegexp.FindStringSubmatch(rcpt)
|
|
} else {
|
|
match = parseRcptBrokenRegexp.FindStringSubmatch(rcpt)
|
|
}
|
|
if len(match) != 2 {
|
|
return "", errors.New("Invalid syntax in RCPT command")
|
|
}
|
|
return match[1], nil
|
|
}
|