From 657d6632f3e3ebe8a5227bb84385c01a85f19faf Mon Sep 17 00:00:00 2001 From: Daniel Cormier Date: Thu, 17 May 2018 10:10:43 -0400 Subject: [PATCH 1/4] Added support for SMTP extensions --- extension.go | 16 ++++++++++++++++ protocol.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 extension.go diff --git a/extension.go b/extension.go new file mode 100644 index 0000000..c1b9b03 --- /dev/null +++ b/extension.go @@ -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 +} diff --git a/protocol.go b/protocol.go index a09f82d..6ddc6f5 100644 --- a/protocol.go +++ b/protocol.go @@ -69,6 +69,14 @@ 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 us 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. @@ -106,6 +114,8 @@ func NewProtocol() *Protocol { State: INVALID, MaximumLineLength: -1, MaximumRecipients: -1, + Extensions: []Extension{&smtpUTF8{}}, + ExtensionData: make(map[interface{}]interface{}), } p.resetState() return p @@ -222,6 +232,18 @@ func (proto *Protocol) Command(command *Command) (reply *Reply) { return r } } + if proto.Message.IsEHLO { + 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") @@ -412,6 +434,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.Message.IsEHLO = false return ReplyOk("Hello " + args) } @@ -420,6 +443,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.Message.IsEHLO = true replyArgs := []string{"Hello " + args, "PIPELINING"} if proto.TLSHandler != nil && !proto.TLSPending && !proto.TLSUpgraded { @@ -434,6 +458,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...) } From 97bf39333c35dfe2f67980d956b25300172d946b Mon Sep 17 00:00:00 2001 From: Daniel Cormier Date: Thu, 17 May 2018 10:11:22 -0400 Subject: [PATCH 2/4] Added an extension to support RFC 6531 (SMTPUTF8) --- protocol.go | 24 +++++++++++-------- protocol_test.go | 37 +++++++++++++++++++++++++---- smtputf8.go | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 smtputf8.go diff --git a/protocol.go b/protocol.go index 6ddc6f5..70923e6 100644 --- a/protocol.go +++ b/protocol.go @@ -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 @@ -71,11 +72,13 @@ type Protocol struct { 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. + // 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 us an unexported type as the key to avoid colissions (similar to keys for context.Context). + // 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. // @@ -115,7 +118,6 @@ func NewProtocol() *Protocol { MaximumLineLength: -1, MaximumRecipients: -1, Extensions: []Extension{&smtpUTF8{}}, - ExtensionData: make(map[interface{}]interface{}), } p.resetState() return p @@ -123,6 +125,7 @@ func NewProtocol() *Protocol { func (proto *Protocol) resetState() { proto.Message = &data.SMTPMessage{} + proto.ExtensionData = make(map[interface{}]interface{}) } func (proto *Protocol) logf(message string, args ...interface{}) { @@ -232,7 +235,7 @@ func (proto *Protocol) Command(command *Command) (reply *Reply) { return r } } - if proto.Message.IsEHLO { + if proto.isExtendedSMTP { for _, ext := range proto.Extensions { if proto.TLSUpgraded || !ext.TLSOnly() { proto.logf("sending to extension %s (%T)", ext.EHLOKeyword(), ext) @@ -251,8 +254,8 @@ func (proto *Protocol) Command(command *Command) (reply *Reply) { 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]) @@ -434,7 +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.Message.IsEHLO = false + proto.isExtendedSMTP = false return ReplyOk("Hello " + args) } @@ -443,7 +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.Message.IsEHLO = true + proto.isExtendedSMTP = true replyArgs := []string{"Hello " + args, "PIPELINING"} if proto.TLSHandler != nil && !proto.TLSPending && !proto.TLSUpgraded { @@ -488,6 +491,7 @@ func (proto *Protocol) STARTTLS(args string) (reply *Reply) { proto.TLSPending = ok if ok { proto.resetState() + proto.isExtendedSMTP = false proto.State = ESTABLISH } }) diff --git a/protocol_test.go b/protocol_test.go index 45f0125..20b1dfa 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -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") }) @@ -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") }) @@ -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") }) @@ -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") }) @@ -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() { @@ -947,3 +947,30 @@ 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() + proto.Command(ParseCommand("EHLO localhost")) + proto.Command(ParseCommand("MAIL FROM:")) + 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() + proto.Command(ParseCommand("EHLO localhost")) + proto.Command(ParseCommand("MAIL FROM: 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) + }) +} diff --git a/smtputf8.go b/smtputf8.go new file mode 100644 index 0000000..adf2b69 --- /dev/null +++ b/smtputf8.go @@ -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 +} From 9194c48d1169b4f04b557ac45e4b8756c1a70514 Mon Sep 17 00:00:00 2001 From: Daniel Cormier Date: Thu, 17 May 2018 10:57:41 -0400 Subject: [PATCH 3/4] EHLO response should include SMTPUTF8 extension --- protocol_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/protocol_test.go b/protocol_test.go index 20b1dfa..c5270e5 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -952,10 +952,11 @@ func TestUnicodeAddressSupport(t *testing.T) { Convey("Unexpected non-ASCII chars should be rejected", t, func() { proto := NewProtocol() proto.Start() - proto.Command(ParseCommand("EHLO localhost")) + 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:")) So(proto.State, ShouldEqual, RCPT) - reply := proto.Command(ParseCommand("RCPT To:<🐖@mailhog.example>")) + 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"}) @@ -964,10 +965,11 @@ func TestUnicodeAddressSupport(t *testing.T) { Convey("Expected non-ASCII chars should be accepted", t, func() { proto := NewProtocol() proto.Start() - proto.Command(ParseCommand("EHLO localhost")) + 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: SMTPUTF8")) So(proto.State, ShouldEqual, RCPT) - reply := proto.Command(ParseCommand("RCPT To:<🐖@mailhog.example>")) + 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"}) From cf1772ed3e844e5b12f2e66bcab62d6fc8506e15 Mon Sep 17 00:00:00 2001 From: Daniel Cormier Date: Tue, 12 Jun 2018 12:19:14 -0400 Subject: [PATCH 4/4] Updated README to note support for SMTPUTF8 extension --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7ca37cc..140c175 100644 --- a/README.md +++ b/README.md @@ -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()