Repository: emersion/go-smtp
Pull request #66 (2019-09-26 20:24:23)

server: Implement per-recipient status for LMTP
Main differences from #56 are mentioned in the commit message:

>A separate interface is used so backend implementation will be aware
>of LMTP usage. This also avoids clutter in simple backend
>implementations that don't support LMTP.

Though I am not confident about this. Perhaps, having an argument for the Session.Data that is set to nil for non-LMTP would work better?

>RFC 2033 (Section 4.2) says that we should send multiple status lines if
>the same address was specified multiple times. We move responsibility of
>doing this to the backend implementation and don't do any deduplication
>themselves. This allows transparent forwarding for LMTP to be
>implemented correctly.

And this PR does not mess with LMTP/TCP or contexts, of course.

codecov[bot]: # [Codecov](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=h1) Report
> Merging [#66](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=desc) into [master](https://codecov.io/gh/emersion/go-smtp/commit/77d262d6f7b78a2993712e91d7289d2503f27640?src=pr&el=desc) will **increase** coverage by `1.46%`.
> The diff coverage is `85.96%`.

[![Impacted file tree graph](https://codecov.io/gh/emersion/go-smtp/pull/66/graphs/tree.svg?width=650&token=vj7HO8ABwC&height=150&src=pr)](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master     #66      +/-   ##
=========================================
+ Coverage   63.73%   65.2%   +1.46%     
=========================================
  Files           8       8              
  Lines         808     842      +34     
=========================================
+ Hits          515     549      +34     
+ Misses        214     213       -1     
- Partials       79      80       +1
```


| [Impacted Files](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [conn.go](https://codecov.io/gh/emersion/go-smtp/pull/66/diff?src=pr&el=tree#diff-Y29ubi5nbw==) | `64.67% <85.96%> (+3.59%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=footer). Last update [77d262d...3d0e1f6](https://codecov.io/gh/emersion/go-smtp/pull/66?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/backend.go b/backend.go
index 6c247c0..75cce17 100644
--- a/backend.go
+++ b/backend.go
@@ -21,6 +21,23 @@ type Backend interface {
 	AnonymousLogin(state *ConnectionState) (Session, error)
 }
 
+// MailOptions contains custom arguments that were
+// passed as an argument to the MAIL command.
+type MailOptions struct {
+	// Size of the body. Can be 0 if not specified by client.
+	Size int
+
+	// TLS is required for the message transmission.
+	//
+	// The message should be rejected if it can't be transmitted
+	// with TLS.
+	RequireTLS bool
+
+	// The message envelope or message header contains UTF-8-encoded strings.
+	// This flag is set by SMTPUTF8-aware (RFC 6531) client.
+	UTF8 bool
+}
+
 type Session interface {
 	// Discard currently processed message.
 	Reset()
@@ -29,9 +46,30 @@ type Session interface {
 	Logout() error
 
 	// Set return path for currently processed message.
-	Mail(from string) error
+	Mail(from string, opts MailOptions) error
 	// Add recipient for currently processed message.
 	Rcpt(to string) error
 	// Set currently processed message contents and send it.
 	Data(r io.Reader) error
 }
+
+type LMTPSession interface {
+	// LMTPData is the LMTP-specific version of Data method.
+	// It can be optionally implemented by the backend to provide
+	// per-recipient status information when it is used over LMTP
+	// protocol.
+	//
+	// LMTPData implementation sets status information using passed
+	// StatusCollector by calling SetStatus once per each AddRcpt
+	// call, even if AddRcpt was called multiple times with
+	// the same argument. SetStatus must not be called after
+	// LMTPData returns.
+	//
+	// Return value of LMTPData itself is used as a status for
+	// recipients that got no status set before using StatusCollector.
+	LMTPData(r io.Reader, status StatusCollector) error
+}
+
+type StatusCollector interface {
+	SetStatus(rcptTo string, err error)
+}
diff --git a/backendutil/transform.go b/backendutil/transform.go
index 76a1bac..a5ff666 100755
--- a/backendutil/transform.go
+++ b/backendutil/transform.go
@@ -43,7 +43,7 @@ func (s *transformSession) Reset() {
 	s.Session.Reset()
 }
 
-func (s *transformSession) Mail(from string) error {
+func (s *transformSession) Mail(from string, opts smtp.MailOptions) error {
 	if s.be.TransformMail != nil {
 		var err error
 		from, err = s.be.TransformMail(from)
@@ -51,7 +51,7 @@ func (s *transformSession) Mail(from string) error {
 			return err
 		}
 	}
-	return s.Session.Mail(from)
+	return s.Session.Mail(from, opts)
 }
 
 func (s *transformSession) Rcpt(to string) error {
diff --git a/client.go b/client.go
index 43a9e01..a0a9bc8 100644
--- a/client.go
+++ b/client.go
@@ -298,8 +298,11 @@ func (c *Client) Auth(a sasl.Client) error {
 // parameter.
 // This initiates a mail transaction and is followed by one or more Rcpt calls.
 //
+// If opts is not nil, MAIL arguments provided in the structure will be added
+// to the command. Handling of unsupported options depends on the extension.
+//
 // If server returns an error, it will be of type *SMTPError.
-func (c *Client) Mail(from string) error {
+func (c *Client) Mail(from string, opts *MailOptions) error {
 	if err := validateLine(from); err != nil {
 		return err
 	}
@@ -307,11 +310,27 @@ func (c *Client) Mail(from string) error {
 		return err
 	}
 	cmdStr := "MAIL FROM:<%s>"
-	if c.ext != nil {
-		if _, ok := c.ext["8BITMIME"]; ok {
-			cmdStr += " BODY=8BITMIME"
+	if _, ok := c.ext["8BITMIME"]; ok {
+		cmdStr += " BODY=8BITMIME"
+	}
+	if _, ok := c.ext["SIZE"]; ok && opts != nil && opts.Size != 0 {
+		cmdStr += " SIZE=" + strconv.Itoa(opts.Size)
+	}
+	if opts != nil && opts.RequireTLS {
+		if _, ok := c.ext["REQUIRETLS"]; ok {
+			cmdStr += " REQUIRETLS"
+		} else {
+			return errors.New("smtp: server does not support REQUIRETLS")
 		}
 	}
+	if opts != nil && opts.UTF8 {
+		if _, ok := c.ext["SMTPUTF8"]; ok {
+			cmdStr += " SMTPUTF8"
+		} else {
+			return errors.New("smtp: server does not support SMTPUTF8")
+		}
+	}
+
 	_, _, err := c.cmd(250, cmdStr, from)
 	return err
 }
@@ -428,7 +447,7 @@ func SendMail(addr string, a sasl.Client, from string, to []string, r io.Reader)
 			return err
 		}
 	}
-	if err = c.Mail(from); err != nil {
+	if err = c.Mail(from, nil); err != nil {
 		return err
 	}
 	for _, addr := range to {
diff --git a/conn.go b/conn.go
index 79c4f53..595da1a 100644
--- a/conn.go
+++ b/conn.go
@@ -167,6 +167,7 @@ func (c *Conn) SetSession(session Session) {
 func (c *Conn) Close() error {
 	if session := c.Session(); session != nil {
 		session.Logout()
+		c.SetSession(nil)
 	}
 
 	return c.conn.Close()
@@ -234,6 +235,12 @@ func (c *Conn) handleGreet(enhanced bool, arg string) {
 
 			caps = append(caps, authCap)
 		}
+		if c.server.EnableSMTPUTF8 {
+			caps = append(caps, "SMTPUTF8")
+		}
+		if _, isTLS := c.TLSConnectionState(); isTLS && c.server.EnableREQUIRETLS {
+			caps = append(caps, "REQUIRETLS")
+		}
 		if c.server.MaxMessageBytes > 0 {
 			caps = append(caps, fmt.Sprintf("SIZE %v", c.server.MaxMessageBytes))
 		}
@@ -284,6 +291,8 @@ func (c *Conn) handleMail(arg string) {
 	}
 	from = strings.Trim(from, "<>")
 
+	opts := MailOptions{}
+
 	// This is where the Conn may put BODY=8BITMIME, but we already
 	// read the DATA as bytes, so it does not effect our processing.
 	if len(fromArgs) > 1 {
@@ -304,10 +313,18 @@ func (c *Conn) handleMail(arg string) {
 				c.WriteResponse(552, EnhancedCode{5, 3, 4}, "Max message size exceeded")
 				return
 			}
+
+			opts.Size = int(size)
 		}
+
+		_, ok := args["SMTPUTF8"]
+		opts.UTF8 = ok
+
+		_, ok = args["REQUIRETLS"]
+		opts.RequireTLS = ok
 	}
 
-	if err := c.Session().Mail(from); err != nil {
+	if err := c.Session().Mail(from, opts); err != nil {
 		if smtpErr, ok := err.(*SMTPError); ok {
 			c.WriteResponse(smtpErr.Code, smtpErr.EnhancedCode, smtpErr.Message)
 			return
@@ -364,6 +381,11 @@ func (c *Conn) handleAuth(arg string) {
 		return
 	}
 
+	if _, isTLS := c.TLSConnectionState(); !isTLS && !c.server.AllowInsecureAuth {
+		c.WriteResponse(523, EnhancedCode{5, 7, 10}, "TLS is required")
+		return
+	}
+
 	mechanism := strings.ToUpper(parts[0])
 
 	// Parse client initial response if there is one
@@ -466,40 +488,114 @@ func (c *Conn) handleData(arg string) {
 	// We have recipients, go to accept data
 	c.WriteResponse(354, EnhancedCode{2, 0, 0}, "Go ahead. End your data with <CR><LF>.<CR><LF>")
 
-	var (
-		code         int
-		enhancedCode EnhancedCode
-		msg          string
-	)
+	defer c.reset()
+
+	if c.server.LMTP {
+		c.handleDataLMTP()
+		return
+	}
+
 	r := newDataReader(c)
-	err := c.Session().Data(r)
+	code, enhancedCode, msg := toSMTPStatus(c.Session().Data(r))
 	io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed
-	if err != nil {
-		if smtperr, ok := err.(*SMTPError); ok {
-			code = smtperr.Code
-			enhancedCode = smtperr.EnhancedCode
-			msg = smtperr.Message
-		} else {
-			code = 554
-			enhancedCode = EnhancedCode{5, 0, 0}
-			msg = "Error: transaction failed, blame it on the weather: " + err.Error()
+	c.WriteResponse(code, enhancedCode, msg)
+
+}
+
+type statusCollector struct {
+	// Contains map from recipient to list of channels that are used for that
+	// recipient.
+	statusMap map[string]chan error
+
+	// Contains channels from statusMap, in the same
+	// order as Conn.recipients.
+	status []chan error
+}
+
+// fillRemaining sets status for all recipients SetStatus was not called for before.
+func (s *statusCollector) fillRemaining(err error) {
+	// Amount of times certain recipient was specified is indicated by the channel
+	// buffer size, so once we fill it, we can be confident that we sent
+	// at least as much statuses as needed. Extra statuses will be ignored anyway.
+chLoop:
+	for _, ch := range s.statusMap {
+		for {
+			select {
+			case ch <- err:
+			default:
+				continue chLoop
+			}
 		}
-	} else {
-		code = 250
-		enhancedCode = EnhancedCode{2, 0, 0}
-		msg = "OK: queued"
+	}
+}
+
+func (s *statusCollector) SetStatus(rcptTo string, err error) {
+	ch := s.statusMap[rcptTo]
+	if ch == nil {
+		panic("SetStatus is called for recipient that was not specified before")
 	}
 
-	if c.server.LMTP {
-		// TODO: support per-recipient responses
+	select {
+	case ch <- err:
+	default:
+		// There enough buffer space to fit all statuses at once, if this is
+		// not the case - backend is doing something wrong.
+		panic("SetStatus is called more times than particular recipient was specified")
+	}
+}
+
+func (c *Conn) handleDataLMTP() {
+	r := newDataReader(c)
+
+	rcptCounts := make(map[string]int, len(c.recipients))
+
+	status := &statusCollector{
+		statusMap: make(map[string]chan error, len(c.recipients)),
+		status:    make([]chan error, 0, len(c.recipients)),
+	}
+	for _, rcpt := range c.recipients {
+		rcptCounts[rcpt]++
+	}
+	// Create channels with buffer sizes necessary to fit all
+	// statuses for a single recipient to avoid deadlocks.
+	for rcpt, count := range rcptCounts {
+		status.statusMap[rcpt] = make(chan error, count)
+	}
+	for _, rcpt := range c.recipients {
+		status.status = append(status.status, status.statusMap[rcpt])
+	}
+
+	lmtpSession, ok := c.Session().(LMTPSession)
+	if !ok {
+		// Fallback to using a single status for all recipients.
+		err := c.Session().Data(r)
+		io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed
 		for _, rcpt := range c.recipients {
-			c.WriteResponse(code, enhancedCode, "<"+rcpt+"> "+msg)
+			status.SetStatus(rcpt, err)
 		}
 	} else {
-		c.WriteResponse(code, enhancedCode, msg)
+		go func() {
+			status.fillRemaining(lmtpSession.LMTPData(r, status))
+			io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed
+		}()
 	}
 
-	c.reset()
+	for i, rcpt := range c.recipients {
+		code, enchCode, msg := toSMTPStatus(<-status.status[i])
+		c.WriteResponse(code, enchCode, "<"+rcpt+"> "+msg)
+	}
+}
+
+func toSMTPStatus(err error) (code int, enchCode EnhancedCode, msg string) {
+	if err != nil {
+		if smtperr, ok := err.(*SMTPError); ok {
+			return smtperr.Code, smtperr.EnhancedCode, smtperr.Message
+		} else {
+			return 554, EnhancedCode{5, 0, 0}, "Error: transaction failed, blame it on the weather: " + err.Error()
+		}
+	}
+
+	return 250, EnhancedCode{2, 0, 0}, "OK: queued"
 }
 
 func (c *Conn) Reject() {
diff --git a/go.mod b/go.mod
index 4d72813..24eea4d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
 module github.com/emersion/go-smtp
 
 require github.com/emersion/go-sasl v0.0.0-20190704090222-36b50694675c
+
+go 1.13
diff --git a/server.go b/server.go
index 29250b8..7f8d52c 100755
--- a/server.go
+++ b/server.go
@@ -45,6 +45,14 @@ type Server struct {
 	ReadTimeout       time.Duration
 	WriteTimeout      time.Duration
 
+	// Advertise SMTPUTF8 (RFC 6531) capability.
+	// Should be used only if backend supports it.
+	EnableSMTPUTF8 bool
+
+	// Advertise REQUIRETLS (draft-ietf-uta-smtp-require-tls-09) capability.
+	// Should be used only if backend supports it.
+	EnableREQUIRETLS bool
+
 	// If set, the AUTH command will not be advertised and authentication
 	// attempts will be rejected. This setting overrides AllowInsecureAuth.
 	AuthDisabled bool
@@ -55,6 +63,7 @@ type Server struct {
 	listener net.Listener
 	caps     []string
 	auths    map[string]SaslServerFactory
+	done     chan struct{}
 
 	locker sync.Mutex
 	conns  map[*Conn]struct{}
@@ -67,6 +76,7 @@ func NewServer(be Backend) *Server {
 		MaxLineLength: 2000,
 
 		Backend:  be,
+		done:     make(chan struct{}, 1),
 		ErrorLog: log.New(os.Stderr, "smtp/server ", log.LstdFlags),
 		caps:     []string{"PIPELINING", "8BITMIME", "ENHANCEDSTATUSCODES"},
 		auths: map[string]SaslServerFactory{
@@ -99,7 +109,13 @@ func (s *Server) Serve(l net.Listener) error {
 	for {
 		c, err := l.Accept()
 		if err != nil {
-			return err
+			select {
+			case <-s.done:
+				// we called Close()
+				return nil
+			default:
+				return err
+			}
 		}
 
 		go s.handleConn(newConn(c, s))
@@ -199,6 +215,7 @@ func (s *Server) ListenAndServeTLS() error {
 
 // Close stops the server.
 func (s *Server) Close() {
+	s.done <- struct{}{}
 	s.listener.Close()
 
 	s.locker.Lock()
diff --git a/smtp.go b/smtp.go
index 1355634..e5045a7 100644
--- a/smtp.go
+++ b/smtp.go
@@ -6,6 +6,8 @@
 //	AUTH      		RFC 2554
 //	STARTTLS  		RFC 3207
 //	ENHANCEDSTATUSCODES	RFC 2034
+//  SMTPUTF8		RFC 6531
+//  REQUIRETLS		draft-ietf-uta-smtp-require-tls-09
 //
 // LMTP (RFC 2033) is also supported.
 //


Test patch:
diff --git a/backendutil/transform_test.go b/backendutil/transform_test.go
index cd711a7..4505d0d 100755
--- a/backendutil/transform_test.go
+++ b/backendutil/transform_test.go
@@ -63,7 +63,7 @@ func (s *session) Logout() error {
 	return nil
 }
 
-func (s *session) Mail(from string) error {
+func (s *session) Mail(from string, opts smtp.MailOptions) error {
 	s.Reset()
 	s.msg.From = from
 	return nil
diff --git a/client_test.go b/client_test.go
index 27f493c..fcb32db 100644
--- a/client_test.go
+++ b/client_test.go
@@ -99,7 +99,7 @@ func TestBasic(t *testing.T) {
 		t.Fatalf("Shouldn't support DSN")
 	}
 
-	if err := c.Mail("user@gmail.com"); err == nil {
+	if err := c.Mail("user@gmail.com", nil); err == nil {
 		t.Fatalf("MAIL should require authentication")
 	}
 
@@ -123,10 +123,10 @@ func TestBasic(t *testing.T) {
 	if err := c.Rcpt("golang-nuts@googlegroups.com>\r\nDATA\r\nInjected message body\r\n.\r\nQUIT\r\n"); err == nil {
 		t.Fatalf("RCPT should have failed due to a message injection attempt")
 	}
-	if err := c.Mail("user@gmail.com>\r\nDATA\r\nAnother injected message body\r\n.\r\nQUIT\r\n"); err == nil {
+	if err := c.Mail("user@gmail.com>\r\nDATA\r\nAnother injected message body\r\n.\r\nQUIT\r\n", nil); err == nil {
 		t.Fatalf("MAIL should have failed due to a message injection attempt")
 	}
-	if err := c.Mail("user@gmail.com"); err != nil {
+	if err := c.Mail("user@gmail.com", nil); err != nil {
 		t.Fatalf("MAIL failed: %s", err)
 	}
 	if err := c.Rcpt("golang-nuts@googlegroups.com"); err != nil {
@@ -187,7 +187,7 @@ func TestBasic_SMTPError(t *testing.T) {
 		t.Fatalf("NewClient failed: %v", err)
 	}
 
-	err = c.Mail("whatever")
+	err = c.Mail("whatever", nil)
 	if err == nil {
 		t.Fatal("MAIL succeded")
 	}
@@ -205,7 +205,7 @@ func TestBasic_SMTPError(t *testing.T) {
 		t.Fatalf("Wrong message, got %s, want %s", smtpErr.Message, "Failing with enhanced code")
 	}
 
-	err = c.Mail("whatever")
+	err = c.Mail("whatever", nil)
 	if err == nil {
 		t.Fatal("MAIL succeded")
 	}
@@ -385,7 +385,7 @@ func TestHello(t *testing.T) {
 			c.serverName = "smtp.google.com"
 			err = c.Auth(sasl.NewPlainClient("", "user", "pass"))
 		case 4:
-			err = c.Mail("test@example.com")
+			err = c.Mail("test@example.com", nil)
 		case 5:
 			ok, _ := c.Extension("feature")
 			if ok {
@@ -851,7 +851,7 @@ func TestLMTP(t *testing.T) {
 	}
 	c.didHello = true
 
-	if err := c.Mail("user@gmail.com"); err != nil {
+	if err := c.Mail("user@gmail.com", nil); err != nil {
 		t.Fatalf("MAIL failed: %s", err)
 	}
 	if err := c.Rcpt("golang-nuts@googlegroups.com"); err != nil {
diff --git a/example_test.go b/example_test.go
index 2d040c6..607c971 100644
--- a/example_test.go
+++ b/example_test.go
@@ -25,7 +25,7 @@ func ExampleDial() {
 	}
 
 	// Set the sender and recipient first
-	if err := c.Mail("sender@example.org"); err != nil {
+	if err := c.Mail("sender@example.org", nil); err != nil {
 		log.Fatal(err)
 	}
 	if err := c.Rcpt("recipient@example.net"); err != nil {
@@ -108,7 +108,7 @@ func (bkd *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, e
 // A Session is returned after successful login.
 type Session struct{}
 
-func (s *Session) Mail(from string) error {
+func (s *Session) Mail(from string, opts smtp.MailOptions) error {
 	log.Println("Mail from:", from)
 	return nil
 }
diff --git a/lmtp_server_test.go b/lmtp_server_test.go
new file mode 100644
index 0000000..9cd98c2
--- /dev/null
+++ b/lmtp_server_test.go
@@ -0,0 +1,197 @@
+package smtp_test
+
+import (
+	"bufio"
+	"errors"
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/emersion/go-smtp"
+)
+
+func sendDeliveryCmdsLMTP(t *testing.T, scanner *bufio.Scanner, c io.Writer) {
+	sendLHLO(t, scanner, c)
+
+	io.WriteString(c, "MAIL FROM:<root@nsa.gov>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "RCPT TO:<root@bnd.bund.de>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "DATA\r\n")
+	scanner.Scan()
+	io.WriteString(c, "Hey <3\r\n")
+	io.WriteString(c, ".\r\n")
+}
+
+func sendLHLO(t *testing.T, scanner *bufio.Scanner, c io.Writer) {
+	io.WriteString(c, "LHLO localhost\r\n")
+	scanner.Scan()
+	if scanner.Text() != "250-Hello localhost" {
+		t.Fatal("Invalid LHLO response:", scanner.Text())
+	}
+	for scanner.Scan() {
+		s := scanner.Text()
+
+		if strings.HasPrefix(s, "250 ") {
+			break
+		} else if !strings.HasPrefix(s, "250-") {
+			t.Fatal("Invalid capability response:", s)
+		}
+	}
+}
+
+func TestServer_LMTP(t *testing.T) {
+	be, s, c, scanner := testServerGreeted(t, func(s *smtp.Server) {
+		s.LMTP = true
+		be := s.Backend.(*backend)
+		be.implementLMTPData = true
+		be.lmtpStatus = []struct {
+			addr string
+			err  error
+		}{
+			{"root@gchq.gov.uk", errors.New("nah")},
+			{"root@bnd.bund.de", nil},
+		}
+	})
+	defer s.Close()
+	defer c.Close()
+
+	sendDeliveryCmdsLMTP(t, scanner, c)
+
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "554 5.0.0 <root@gchq.gov.uk>") {
+		t.Fatal("Invalid DATA first response:", scanner.Text())
+	}
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid DATA second response:", scanner.Text())
+	}
+
+	if len(be.messages) != 0 || len(be.anonmsgs) != 1 {
+		t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs)
+	}
+}
+
+func TestServer_LMTP_Early(t *testing.T) {
+	// This test confirms responses are sent as early as possible
+	// e.g. right after SetStatus is called.
+
+	lmtpStatusSync := make(chan struct{})
+
+	be, s, c, scanner := testServerGreeted(t, func(s *smtp.Server) {
+		s.LMTP = true
+		be := s.Backend.(*backend)
+		be.implementLMTPData = true
+		be.lmtpStatusSync = lmtpStatusSync
+		be.lmtpStatus = []struct {
+			addr string
+			err  error
+		}{
+			{"root@gchq.gov.uk", errors.New("nah")},
+			{"root@bnd.bund.de", nil},
+		}
+	})
+	defer s.Close()
+	defer c.Close()
+
+	sendDeliveryCmdsLMTP(t, scanner, c)
+
+	// Test backend sends to sync channel after calling SetStatus.
+
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "554 5.0.0 <root@gchq.gov.uk>") {
+		t.Fatal("Invalid DATA first response:", scanner.Text())
+	}
+
+	<-be.lmtpStatusSync
+
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid DATA second response:", scanner.Text())
+	}
+
+	<-be.lmtpStatusSync
+
+	if len(be.messages) != 0 || len(be.anonmsgs) != 1 {
+		t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs)
+	}
+}
+
+func TestServer_LMTP_Expand(t *testing.T) {
+	// This checks whether handleDataLMTP
+	// correctly expands results if backend doesn't
+	// implement LMTPSession.
+
+	be, s, c, scanner := testServerGreeted(t, func(s *smtp.Server) {
+		s.LMTP = true
+	})
+	defer s.Close()
+	defer c.Close()
+
+	sendDeliveryCmdsLMTP(t, scanner, c)
+
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid DATA first response:", scanner.Text())
+	}
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid DATA second response:", scanner.Text())
+	}
+
+	if len(be.messages) != 0 || len(be.anonmsgs) != 1 {
+		t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs)
+	}
+}
+
+func TestServer_LMTP_DuplicatedRcpt(t *testing.T) {
+	be, s, c, scanner := testServerGreeted(t, func(s *smtp.Server) {
+		s.LMTP = true
+		be := s.Backend.(*backend)
+		be.implementLMTPData = true
+		be.lmtpStatus = []struct {
+			addr string
+			err  error
+		}{
+			{"root@gchq.gov.uk", &smtp.SMTPError{Code: 555}},
+			{"root@bnd.bund.de", nil},
+			{"root@gchq.gov.uk", &smtp.SMTPError{Code: 556}},
+		}
+	})
+	defer s.Close()
+	defer c.Close()
+
+	sendLHLO(t, scanner, c)
+
+	io.WriteString(c, "MAIL FROM:<root@nsa.gov>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "RCPT TO:<root@bnd.bund.de>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk>\r\n")
+	scanner.Scan()
+	io.WriteString(c, "DATA\r\n")
+	scanner.Scan()
+	io.WriteString(c, "Hey <3\r\n")
+	io.WriteString(c, ".\r\n")
+
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "555 5.0.0 <root@gchq.gov.uk>") {
+		t.Fatal("Invalid DATA first response:", scanner.Text())
+	}
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid DATA second response:", scanner.Text())
+	}
+	scanner.Scan()
+	if !strings.HasPrefix(scanner.Text(), "556 5.0.0 <root@gchq.gov.uk>") {
+		t.Fatal("Invalid DATA first response:", scanner.Text())
+	}
+
+	if len(be.messages) != 0 || len(be.anonmsgs) != 1 {
+		t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs)
+	}
+}
diff --git a/server_test.go b/server_test.go
index 6fceac9..b684f48 100644
--- a/server_test.go
+++ b/server_test.go
@@ -23,6 +23,13 @@ type backend struct {
 	messages []*message
 	anonmsgs []*message
 
+	implementLMTPData bool
+	lmtpStatus        []struct {
+		addr string
+		err  error
+	}
+	lmtpStatusSync chan struct{}
+
 	panicOnMail bool
 	userErr     error
 }
@@ -35,6 +42,11 @@ func (be *backend) Login(_ *smtp.ConnectionState, username, password string) (sm
 	if username != "username" || password != "password" {
 		return nil, errors.New("Invalid username or password")
 	}
+
+	if be.implementLMTPData {
+		return &lmtpSession{&session{backend: be}}, nil
+	}
+
 	return &session{backend: be}, nil
 }
 
@@ -43,9 +55,17 @@ func (be *backend) AnonymousLogin(_ *smtp.ConnectionState) (smtp.Session, error)
 		return &session{}, be.userErr
 	}
 
+	if be.implementLMTPData {
+		return &lmtpSession{&session{backend: be, anonymous: true}}, nil
+	}
+
 	return &session{backend: be, anonymous: true}, nil
 }
 
+type lmtpSession struct {
+	*session
+}
+
 type session struct {
 	backend   *backend
 	anonymous bool
@@ -61,7 +81,7 @@ func (s *session) Logout() error {
 	return nil
 }
 
-func (s *session) Mail(from string) error {
+func (s *session) Mail(from string, opts smtp.MailOptions) error {
 	if s.backend.panicOnMail {
 		panic("Everything is on fire!")
 	}
@@ -89,6 +109,22 @@ func (s *session) Data(r io.Reader) error {
 	return nil
 }
 
+func (s *session) LMTPData(r io.Reader, collector smtp.StatusCollector) error {
+	if err := s.Data(r); err != nil {
+		return err
+	}
+
+	for _, val := range s.backend.lmtpStatus {
+		collector.SetStatus(val.addr, val.err)
+
+		if s.backend.lmtpStatusSync != nil {
+			s.backend.lmtpStatusSync <- struct{}{}
+		}
+	}
+
+	return nil
+}
+
 type serverConfigureFunc func(*smtp.Server)
 
 var (
@@ -585,51 +621,3 @@ func TestStrictServerBad(t *testing.T) {
 		t.Fatal("Invalid MAIL response:", scanner.Text())
 	}
 }
-
-func TestServer_lmtpOK(t *testing.T) {
-	be, s, c, scanner := testServerGreeted(t, func(s *smtp.Server) {
-		s.LMTP = true
-	})
-	defer s.Close()
-	defer c.Close()
-
-	io.WriteString(c, "LHLO localhost\r\n")
-
-	scanner.Scan()
-	if scanner.Text() != "250-Hello localhost" {
-		t.Fatal("Invalid LHLO response:", scanner.Text())
-	}
-
-	for scanner.Scan() {
-		s := scanner.Text()
-
-		if strings.HasPrefix(s, "250 ") {
-			break
-		} else if !strings.HasPrefix(s, "250-") {
-			t.Fatal("Invalid capability response:", s)
-		}
-	}
-
-	io.WriteString(c, "MAIL FROM:<root@nsa.gov>\r\n")
-	scanner.Scan()
-	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk>\r\n")
-	scanner.Scan()
-	io.WriteString(c, "RCPT TO:<root@bnd.bund.de>\r\n")
-	scanner.Scan()
-	io.WriteString(c, "DATA\r\n")
-	scanner.Scan()
-	io.WriteString(c, "Hey <3\r\n")
-	io.WriteString(c, ".\r\n")
-	scanner.Scan()
-
-	if !strings.HasPrefix(scanner.Text(), "250 ") {
-		t.Fatal("Invalid DATA first response:", scanner.Text())
-	}
-	if !strings.HasPrefix(scanner.Text(), "250 ") {
-		t.Fatal("Invalid DATA second response:", scanner.Text())
-	}
-
-	if len(be.messages) != 0 || len(be.anonmsgs) != 1 {
-		t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs)
-	}
-}