Repository: emersion/go-smtp (go)

Issue:
server: Switch away from using textproto.DotReader
DotReader does CRLF->LR conversion that is generally unwanted for most email processing needs.

Pull request:
Replace net/textproto DotReader with custom implementation to avoid CRLF->LF rewrite
I copy-pasted net/textproto code and I am not completely sure how it works.
If you want me to improve it - I can try though :)

Closes #108.

Patch:
diff --git a/client.go b/client.go
index f93beda..0b88a08 100644
--- a/client.go
+++ b/client.go
@@ -605,7 +605,12 @@ func toSMTPErr(protoErr *textproto.Error) *SMTPError {
 		return smtpErr
 	}
 
+	msg := parts[1]
+
+	// Per RFC 2034, enhanced code should be prepended to each line.
+	msg = strings.ReplaceAll(msg, "\n"+parts[0]+" ", "\n")
+
 	smtpErr.EnhancedCode = enchCode
-	smtpErr.Message = parts[1]
+	smtpErr.Message = msg
 	return smtpErr
 }
diff --git a/cmd/smtp-debug-server/main.go b/cmd/smtp-debug-server/main.go
new file mode 100644
index 0000000..cb9c48d
--- /dev/null
+++ b/cmd/smtp-debug-server/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+	"flag"
+	"io"
+	"log"
+	"os"
+
+	"github.com/emersion/go-smtp"
+)
+
+var addr string
+
+func init() {
+	flag.StringVar(&addr, "l", "127.0.0.1:1025", "Listen address")
+}
+
+type backend struct{}
+
+func (bkd *backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
+	return &session{}, nil
+}
+
+func (bkd *backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
+	return &session{}, nil
+}
+
+type session struct{}
+
+func (s *session) Mail(from string, opts smtp.MailOptions) error {
+	return nil
+}
+
+func (s *session) Rcpt(to string) error {
+	return nil
+}
+
+func (s *session) Data(r io.Reader) error {
+	return nil
+}
+
+func (s *session) Reset() {}
+
+func (s *session) Logout() error {
+	return nil
+}
+
+func main() {
+	flag.Parse()
+
+	s := smtp.NewServer(&backend{})
+
+	s.Addr = ":1025"
+	s.Domain = "localhost"
+	s.AllowInsecureAuth = true
+	s.Debug = os.Stdout
+
+	log.Println("Starting SMTP server at", addr)
+	log.Fatal(s.ListenAndServe())
+}
diff --git a/data.go b/data.go
index eb071c3..3f11d30 100644
--- a/data.go
+++ b/data.go
@@ -1,6 +1,7 @@
 package smtp
 
 import (
+	"bufio"
 	"io"
 )
 
@@ -42,7 +43,8 @@ var ErrDataTooLarge = &SMTPError{
 }
 
 type dataReader struct {
-	r io.Reader
+	r     *bufio.Reader
+	state int
 
 	limited bool
 	n       int64 // Maximum bytes remaining
@@ -50,7 +52,7 @@ type dataReader struct {
 
 func newDataReader(c *Conn) *dataReader {
 	dr := &dataReader{
-		r: c.text.DotReader(),
+		r: c.text.R,
 	}
 
 	if c.server.MaxMessageBytes > 0 {
@@ -71,7 +73,79 @@ func (r *dataReader) Read(b []byte) (n int, err error) {
 		}
 	}
 
-	n, err = r.r.Read(b)
+	// Code below is taken from net/textproto with only one modification to
+	// not rewrite CRLF -> LF.
+
+	// Run data through a simple state machine to
+	// elide leading dots and detect ending .\r\n line.
+	const (
+		stateBeginLine = iota // beginning of line; initial state; must be zero
+		stateDot              // read . at beginning of line
+		stateDotCR            // read .\r at beginning of line
+		stateCR               // read \r (possibly at end of line)
+		stateData             // reading data in middle of line
+		stateEOF              // reached .\r\n end marker line
+	)
+	for n < len(b) && r.state != stateEOF {
+		var c byte
+		c, err = r.r.ReadByte()
+		if err != nil {
+			if err == io.EOF {
+				err = io.ErrUnexpectedEOF
+			}
+			break
+		}
+		switch r.state {
+		case stateBeginLine:
+			if c == '.' {
+				r.state = stateDot
+				continue
+			}
+			if c == '\r' {
+				r.state = stateCR
+				continue
+			}
+			r.state = stateData
+
+		case stateDot:
+			if c == '\r' {
+				r.state = stateDotCR
+				continue
+			}
+			if c == '\n' {
+				r.state = stateEOF
+				continue
+			}
+			r.state = stateData
+
+		case stateDotCR:
+			if c == '\n' {
+				r.state = stateEOF
+				continue
+			}
+			r.state = stateData
+
+		case stateCR:
+			if c == '\n' {
+				r.state = stateBeginLine
+				break
+			}
+			r.state = stateData
+
+		case stateData:
+			if c == '\r' {
+				r.state = stateCR
+			}
+			if c == '\n' {
+				r.state = stateBeginLine
+			}
+		}
+		b[n] = c
+		n++
+	}
+	if err == nil && r.state == stateEOF {
+		err = io.EOF
+	}
 
 	if r.limited {
 		r.n -= int64(n)


Test patch:
diff --git a/backendutil/transform_test.go b/backendutil/transform_test.go
index 4505d0d..3c77638 100755
--- a/backendutil/transform_test.go
+++ b/backendutil/transform_test.go
@@ -252,7 +252,7 @@ func TestServer(t *testing.T) {
 		t.Fatal("Invalid mail recipients:", msg.To)
 	}
 	// base64 of "Hey <3\n" (with actual newline)
-	if string(msg.Data) != "SGV5IDwzCg==" {
+	if string(msg.Data) != "SGV5IDwzDQo=" {
 		t.Fatal("Invalid mail data:", string(msg.Data))
 	}
 }
@@ -316,8 +316,8 @@ func TestServer_anonymousUserOK(t *testing.T) {
 	if len(msg.To) != 1 || msg.To[0] != "cm9vdEBnY2hxLmdvdi51aw==" {
 		t.Fatal("Invalid mail recipients:", msg.To)
 	}
-	// base64 of "Hey <3\n" (with actual newline)
-	if string(msg.Data) != "SGV5IDwzCg==" {
+	// base64 of "Hey <3\r\n" (with actual newline)
+	if string(msg.Data) != "SGV5IDwzDQo=" {
 		t.Fatal("Invalid mail data:", string(msg.Data))
 	}
 }
diff --git a/client_test.go b/client_test.go
index 1e73c09..4a7793e 100644
--- a/client_test.go
+++ b/client_test.go
@@ -167,7 +167,10 @@ func TestBasic_SMTPError(t *testing.T) {
 250-mx.google.com at your service
 250 ENHANCEDSTATUSCODES
 500 5.0.0 Failing with enhanced code
-500 Failing without enhanced code`
+500 Failing without enhanced code
+500-5.0.0 Failing with multiline and enhanced code
+500 5.0.0 ... still failing
+`
 	// RFC 2034 says that enhanced codes *SHOULD* be included in errors,
 	// this means it can be violated hence we need to handle last
 	// case properly.
@@ -220,6 +223,21 @@ func TestBasic_SMTPError(t *testing.T) {
 	if smtpErr.Message != "Failing without enhanced code" {
 		t.Fatalf("Wrong message, got %s, want %s", smtpErr.Message, "Failing without enhanced code")
 	}
+
+	err = c.Mail("whatever", nil)
+	if err == nil {
+		t.Fatal("MAIL succeded")
+	}
+	smtpErr, ok = err.(*SMTPError)
+	if !ok {
+		t.Fatal("Returned error is not SMTPError")
+	}
+	if smtpErr.Code != 500 {
+		t.Fatalf("Wrong status code, got %d, want %d", smtpErr.Code, 500)
+	}
+	if want := "Failing with multiline and enhanced code\n... still failing"; smtpErr.Message != want {
+		t.Fatalf("Wrong message, got %s, want %s", smtpErr.Message, want)
+	}
 }
 
 func TestClient_TooLongLine(t *testing.T) {
diff --git a/server_test.go b/server_test.go
index cde0c68..ba3f174 100644
--- a/server_test.go
+++ b/server_test.go
@@ -455,7 +455,7 @@ func TestServer(t *testing.T) {
 		t.Fatal("Invalid DATA response:", scanner.Text())
 	}
 
-	io.WriteString(c, "Hey <3\r\n")
+	io.WriteString(c, "Hey\r <3\r\n")
 	io.WriteString(c, ".\r\n")
 	scanner.Scan()
 	if !strings.HasPrefix(scanner.Text(), "250 ") {
@@ -473,7 +473,7 @@ func TestServer(t *testing.T) {
 	if len(msg.To) != 1 || msg.To[0] != "root@gchq.gov.uk" {
 		t.Fatal("Invalid mail recipients:", msg.To)
 	}
-	if string(msg.Data) != "Hey <3\n" {
+	if string(msg.Data) != "Hey\r <3\r\n" {
 		t.Fatal("Invalid mail data:", string(msg.Data))
 	}
 }