Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SMTP extensions; added extension to properly support SMTPUTF8 #3

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ without compromising configurability or requiring specific backend implementatio
* AUTH [RFC4954](http://tools.ietf.org/html/rfc4954)
* PIPELINING [RFC2920](http://tools.ietf.org/html/rfc2920)
* STARTTLS [RFC3207](http://tools.ietf.org/html/rfc3207)
* SMTPUTF8 [RFC6531](http://tools.ietf.org/html/rfc6531)

```go
proto := NewProtocol()
Expand Down
16 changes: 16 additions & 0 deletions extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package smtp

// Extension is an interface for implementing SMTP extensions.
type Extension interface {
// EHLOKeyword is the name of the extension, to be returned in the EHLO response.
// See RFC 5321, section 2.2.2: https://tools.ietf.org/html/rfc5321#section-2.2.2
EHLOKeyword() string

// Process is called for each command. If a reply is returned, the reply is returned to the
// client and processing is ceased.
Process(proto *Protocol, verb, args string) (errorReply *Reply)

// TLSOnly returns true if this extension should only be called, or even shown in the EHLO
// reponse if the connection has been upgraded to TLS.
TLSOnly() bool
}
39 changes: 37 additions & 2 deletions protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ func ParseCommand(line string) *Command {

// Protocol is a state machine representing an SMTP session
type Protocol struct {
lastCommand *Command
lastCommand *Command
isExtendedSMTP bool

TLSPending bool
TLSUpgraded bool
Expand Down Expand Up @@ -69,6 +70,16 @@ type Protocol struct {
// 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)
// Extensions is a slice of Extension. Each registered extension is included in
// the EHLO response. When a command is called, if the SMTPVerbFilter doesn't
// retry a *Reply, the Process method on each Extension will be called, in
// order, until all extensions have been called or one returns a *Reply.
Extensions []Extension
// ExtensionData allows extensions to have storage for this session.
//
// Extensions should take care to use an unexported type as the key to avoid
// colissions (similar to keys for context.Context).
ExtensionData map[interface{}]interface{}
// TLSHandler is called when a STARTTLS command is received.
//
// It should acknowledge the TLS request and set ok to true.
Expand Down Expand Up @@ -106,13 +117,15 @@ func NewProtocol() *Protocol {
State: INVALID,
MaximumLineLength: -1,
MaximumRecipients: -1,
Extensions: []Extension{&smtpUTF8{}},
}
p.resetState()
return p
}

func (proto *Protocol) resetState() {
proto.Message = &data.SMTPMessage{}
proto.ExtensionData = make(map[interface{}]interface{})
}

func (proto *Protocol) logf(message string, args ...interface{}) {
Expand Down Expand Up @@ -222,15 +235,27 @@ func (proto *Protocol) Command(command *Command) (reply *Reply) {
return r
}
}
if proto.isExtendedSMTP {
for _, ext := range proto.Extensions {
if proto.TLSUpgraded || !ext.TLSOnly() {
proto.logf("sending to extension %s (%T)", ext.EHLOKeyword(), ext)
r := ext.Process(proto, command.verb, command.args)
if r != nil {
proto.logf("response returned by extension %s (%T)", ext.EHLOKeyword(), ext)
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.resetState()
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])
Expand Down Expand Up @@ -412,6 +437,7 @@ func (proto *Protocol) HELO(args string) (reply *Reply) {
proto.logf("Got HELO command, switching to MAIL state")
proto.State = MAIL
proto.Message.Helo = args
proto.isExtendedSMTP = false
return ReplyOk("Hello " + args)
}

Expand All @@ -420,6 +446,7 @@ func (proto *Protocol) EHLO(args string) (reply *Reply) {
proto.logf("Got EHLO command, switching to MAIL state")
proto.State = MAIL
proto.Message.Helo = args
proto.isExtendedSMTP = true
replyArgs := []string{"Hello " + args, "PIPELINING"}

if proto.TLSHandler != nil && !proto.TLSPending && !proto.TLSUpgraded {
Expand All @@ -434,6 +461,13 @@ func (proto *Protocol) EHLO(args string) (reply *Reply) {
}
}
}

for _, ext := range proto.Extensions {
if proto.TLSUpgraded || !ext.TLSOnly() {
replyArgs = append(replyArgs, ext.EHLOKeyword())
}
}

return ReplyOk(replyArgs...)
}

Expand All @@ -457,6 +491,7 @@ func (proto *Protocol) STARTTLS(args string) (reply *Reply) {
proto.TLSPending = ok
if ok {
proto.resetState()
proto.isExtendedSMTP = false
proto.State = ESTABLISH
}
})
Expand Down
39 changes: 34 additions & 5 deletions protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func TestEHLO(t *testing.T) {
reply := proto.EHLO("localhost")
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"})
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
So(proto.State, ShouldEqual, MAIL)
So(proto.Message.Helo, ShouldEqual, "localhost")
})
Expand All @@ -292,7 +292,7 @@ func TestEHLO(t *testing.T) {
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"})
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
So(proto.State, ShouldEqual, MAIL)
So(proto.Message.Helo, ShouldEqual, "localhost")
})
Expand All @@ -305,7 +305,7 @@ func TestEHLO(t *testing.T) {
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"})
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
So(proto.State, ShouldEqual, MAIL)
So(proto.Message.Helo, ShouldEqual, "localhost")
})
Expand All @@ -319,7 +319,7 @@ func TestEHLO(t *testing.T) {
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"})
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
So(proto.State, ShouldEqual, MAIL)
So(proto.Message.Helo, ShouldEqual, "localhost")
})
Expand Down Expand Up @@ -708,7 +708,7 @@ func TestAuth(t *testing.T) {
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"})
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
})

Convey("Invalid mechanism should be rejected", t, func() {
Expand Down Expand Up @@ -947,3 +947,32 @@ func TestAuthLogin(t *testing.T) {
So(handlerCalled, ShouldBeTrue)
})
}

func TestUnicodeAddressSupport(t *testing.T) {
Convey("Unexpected non-ASCII chars should be rejected", t, func() {
proto := NewProtocol()
proto.Start()
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
proto.Command(ParseCommand("MAIL FROM:<test>"))
So(proto.State, ShouldEqual, RCPT)
reply = proto.Command(ParseCommand("RCPT To:<🐖@mailhog.example>"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 501)
So(reply.Lines(), ShouldResemble, []string{"501 Syntax error (unexpected non-ASCII address: 🐖@mailhog.example)\r\n"})
So(proto.State, ShouldEqual, RCPT)
})
Convey("Expected non-ASCII chars should be accepted", t, func() {
proto := NewProtocol()
proto.Start()
reply := proto.Command(ParseCommand("EHLO localhost"))
So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250-PIPELINING\r\n", "250 SMTPUTF8\r\n"})
proto.Command(ParseCommand("MAIL FROM:<test> SMTPUTF8"))
So(proto.State, ShouldEqual, RCPT)
reply = proto.Command(ParseCommand("RCPT To:<🐖@mailhog.example>"))
So(reply, ShouldNotBeNil)
So(reply.Status, ShouldEqual, 250)
So(reply.Lines(), ShouldResemble, []string{"250 Recipient 🐖@mailhog.example ok\r\n"})
So(proto.State, ShouldEqual, RCPT)
})
}
62 changes: 62 additions & 0 deletions smtputf8.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package smtp

import (
"fmt"
"strings"
"unicode"
)

type smtpUTF8Key int

const (
clientUTF8Status smtpUTF8Key = iota + 1
)

type smtpUTF8 struct{}

func (ex *smtpUTF8) EHLOKeyword() string {
return "SMTPUTF8"
}

func (ex *smtpUTF8) TLSOnly() bool {
return false
}

func (ex *smtpUTF8) Process(proto *Protocol, verb, args string) *Reply {
switch verb {
case "MAIL":
for _, part := range strings.Split(args, " ") {
if part != "SMTPUTF8" {
continue
}

proto.ExtensionData[clientUTF8Status] = struct{}{}

return nil
}

case "RCPT":
rcpt, err := proto.ParseRCPT(args)
if err != nil {
return nil
}

if _, exists := proto.ExtensionData[clientUTF8Status]; !exists && ex.IsNotASCII(rcpt) {
return ReplySyntaxError(fmt.Sprintf("unexpected non-ASCII address: %s", rcpt))
}

return nil
}

return nil
}

func (*smtpUTF8) IsNotASCII(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return true
}
}

return false
}