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() 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..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 @@ -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. @@ -106,6 +117,7 @@ func NewProtocol() *Protocol { State: INVALID, MaximumLineLength: -1, MaximumRecipients: -1, + Extensions: []Extension{&smtpUTF8{}}, } p.resetState() return p @@ -113,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{}) { @@ -222,6 +235,18 @@ 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") @@ -229,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]) @@ -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) } @@ -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 { @@ -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...) } @@ -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 } }) diff --git a/protocol_test.go b/protocol_test.go index 45f0125..c5270e5 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,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:")) + 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: 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 +}