Merge branch 'master' into outbound
authorRobert Sesek <rsesek@bluestatic.org>
Sun, 24 May 2020 05:39:12 +0000 (01:39 -0400)
committerRobert Sesek <rsesek@bluestatic.org>
Sun, 24 May 2020 05:39:12 +0000 (01:39 -0400)
1  2 
config.go
pop3.go
smtp.go
smtp/conn.go
smtp/conn_test.go
smtp/server.go

diff --combined config.go
index 1ed9d5a00c3becd3ce31e89fbb11fc02aad16fbc,ff695751f579c69698061d13695637d8c8f88d7c..c5f24ca2a6313a8a9543d88cf936a3dc7611671e
+++ b/config.go
@@@ -1,3 -1,9 +1,9 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package main
  
  import (
@@@ -14,8 -20,6 +20,8 @@@ type Config struct 
        Servers []Server
  }
  
 +const MailboxAccount = "mailbox@"
 +
  type Server struct {
        // Domain is the second component of a mail address: <local-part@domain.com>.
        Domain string
diff --combined pop3.go
index ba5fd36aa7879b419de42e490639fb4fb2628914,cada451f5e4a86b0ea05e32956bda66ef86524ed..085cfdfe09ed46170c0c825a5400ed577a0ee594
+++ b/pop3.go
@@@ -1,3 -1,9 +1,9 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package main
  
  import (
        "os"
        "path"
  
-       "github.com/uber-go/zap"
+       "go.uber.org/zap"
  
        "src.bluestatic.org/mailpopbox/pop3"
  )
  
- func runPOP3Server(config Config, log zap.Logger) <-chan ServerControlMessage {
+ func runPOP3Server(config Config, log *zap.Logger) <-chan ServerControlMessage {
        server := pop3Server{
                config:      config,
                controlChan: make(chan ServerControlMessage),
@@@ -28,7 -34,7 +34,7 @@@
  type pop3Server struct {
        config      Config
        controlChan chan ServerControlMessage
-       log         zap.Logger
+       log         *zap.Logger
  }
  
  func (server *pop3Server) run() {
@@@ -98,7 -104,7 +104,7 @@@ func (server *pop3Server) Name() strin
  
  func (server *pop3Server) OpenMailbox(user, pass string) (pop3.Mailbox, error) {
        for _, s := range server.config.Servers {
 -              if user == "mailbox@"+s.Domain && pass == s.MailboxPassword {
 +              if user == MailboxAccount+s.Domain && pass == s.MailboxPassword {
                        return server.openMailbox(s.MaildropPath)
                }
        }
diff --combined smtp.go
index fe0c4b5f47356075986b47d877887b26a209b578,14ead8fc41fd5652ca0fbf5fd614f48812d2fe86..2a27279b636aacc068139be322790ffda3410128
+++ b/smtp.go
@@@ -1,3 -1,9 +1,9 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package main
  
  import (
        "net/mail"
        "os"
        "path"
 -      "strings"
  
-       "github.com/uber-go/zap"
+       "go.uber.org/zap"
  
        "src.bluestatic.org/mailpopbox/smtp"
  )
  
- func runSMTPServer(config Config, log zap.Logger) <-chan ServerControlMessage {
+ func runSMTPServer(config Config, log *zap.Logger) <-chan ServerControlMessage {
        server := smtpServer{
                config:      config,
                controlChan: make(chan ServerControlMessage),
@@@ -27,7 -34,7 +33,7 @@@ type smtpServer struct 
        config    Config
        tlsConfig *tls.Config
  
-       log zap.Logger
+       log *zap.Logger
  
        controlChan chan ServerControlMessage
  }
@@@ -95,28 -102,8 +101,28 @@@ func (server *smtpServer) VerifyAddress
        return smtp.ReplyOK
  }
  
 -func (server *smtpServer) OnEHLO() *smtp.ReplyLine {
 -      return nil
 +func (server *smtpServer) Authenticate(authz, authc, passwd string) bool {
 +      authcAddr, err := mail.ParseAddress(authc)
 +      if err != nil {
 +              return false
 +      }
 +
 +      authzAddr, err := mail.ParseAddress(authz)
 +      if authz != "" && err != nil {
 +              return false
 +      }
 +
 +      domain := smtp.DomainForAddress(*authcAddr)
 +      for _, s := range server.config.Servers {
 +              if domain == s.Domain {
 +                      authOk := authc == MailboxAccount+s.Domain && passwd == s.MailboxPassword
 +                      if authzAddr != nil {
 +                              authOk = authOk && smtp.DomainForAddress(*authzAddr) == domain
 +                      }
 +                      return authOk
 +              }
 +      }
 +      return false
  }
  
  func (server *smtpServer) OnMessageDelivered(en smtp.Envelope) *smtp.ReplyLine {
        return nil
  }
  
 -func (server *smtpServer) maildropForAddress(addr mail.Address) string {
 -      domainIdx := strings.LastIndex(addr.Address, "@")
 -      if domainIdx == -1 {
 -              return ""
 -      }
 -
 -      domain := addr.Address[domainIdx+1:]
 +func (server *smtpServer) RelayMessage(en smtp.Envelope) {
 +      log := server.log.With(zap.String("id", en.ID))
 +      go smtp.RelayMessage(server, en, log)
 +}
  
 +func (server *smtpServer) maildropForAddress(addr mail.Address) string {
 +      domain := smtp.DomainForAddress(addr)
        for _, s := range server.config.Servers {
                if domain == s.Domain {
                        return s.MaildropPath
diff --combined smtp/conn.go
index eb868e4fd17ddf315b04af9b151e489bed423f5b,94db8a8d785ac6c19dca759940e59d8b583bc393..ec1644e327888edc0d0c605ff8fd8ce69866b7f6
@@@ -1,10 -1,14 +1,16 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package smtp
  
  import (
 +      "bytes"
        "crypto/rand"
        "crypto/tls"
 +      "encoding/base64"
        "fmt"
        "net"
        "net/mail"
@@@ -12,7 -16,7 +18,7 @@@
        "strings"
        "time"
  
-       "github.com/uber-go/zap"
+       "go.uber.org/zap"
  )
  
  type state int
@@@ -25,26 -29,6 +31,26 @@@ const 
        stateData
  )
  
 +type delivery int
 +
 +func (d delivery) String() string {
 +      switch d {
 +      case deliverUnknown:
 +              return "unknown"
 +      case deliverInbound:
 +              return "inbound"
 +      case deliverOutbound:
 +              return "outbound"
 +      }
 +      panic("Unknown delivery")
 +}
 +
 +const (
 +      deliverUnknown  delivery = iota
 +      deliverInbound           // Mail is not from one of this server's domains.
 +      deliverOutbound          // Mail IS from one of this server's domains.
 +)
 +
  type connection struct {
        server Server
  
        esmtp bool
        tls   *tls.ConnectionState
  
-       log zap.Logger
+       log *zap.Logger
 +      // The authcid from a PLAIN SASL login. Non-empty iff tls is non-nil and
 +      // doAUTH() succeeded.
 +      authc string
 +
        state
        line string
  
 +      delivery
 +      // For deliverOutbound, replaces the From and Reply-To values.
 +      sendAs *mail.Address
 +
        ehlo     string
        mailFrom *mail.Address
        rcptTo   []mail.Address
  }
  
- func AcceptConnection(netConn net.Conn, server Server, log zap.Logger) {
+ func AcceptConnection(netConn net.Conn, server Server, log *zap.Logger) {
        conn := connection{
                server:     server,
                tp:         textproto.NewConn(netConn),
                        return
                }
  
 -              conn.log.Info("ReadLine()", zap.String("line", conn.line))
 +              lineForLog := conn.line
 +              const authPlain = "AUTH PLAIN "
 +              if strings.HasPrefix(conn.line, authPlain) {
 +                      lineForLog = authPlain + "[redacted]"
 +              }
 +              conn.log.Info("ReadLine()", zap.String("line", lineForLog))
  
                var cmd string
                if _, err = fmt.Sscanf(conn.line, "%s", &cmd); err != nil {
                        conn.doEHLO()
                case "STARTTLS":
                        conn.doSTARTTLS()
 +              case "AUTH":
 +                      conn.doAUTH()
                case "MAIL":
                        conn.doMAIL()
                case "RCPT":
@@@ -202,9 -171,6 +208,9 @@@ func (conn *connection) doEHLO() 
                if conn.server.TLSConfig() != nil && conn.tls == nil {
                        conn.tp.PrintfLine("250-STARTTLS")
                }
 +              if conn.tls != nil {
 +                      conn.tp.PrintfLine("250-AUTH PLAIN")
 +              }
                conn.tp.PrintfLine("250 SIZE %d", 40960000)
        }
  
@@@ -244,72 -210,6 +250,72 @@@ func (conn *connection) doSTARTTLS() 
        conn.log.Info("TLS connection done", zap.String("state", conn.getTransportString()))
  }
  
 +func (conn *connection) doAUTH() {
 +      if conn.state != stateInitial || conn.tls == nil {
 +              conn.reply(ReplyBadSequence)
 +              return
 +      }
 +
 +      if conn.authc != "" {
 +              conn.writeReply(503, "already authenticated")
 +              return
 +      }
 +
 +      var cmd, authType, authString string
 +      n, err := fmt.Sscanf(conn.line, "%s %s %s", &cmd, &authType, &authString)
 +      if n < 2 {
 +              conn.reply(ReplyBadSyntax)
 +              return
 +      }
 +
 +      if authType != "PLAIN" {
 +              conn.writeReply(504, "unrecognized auth type")
 +              return
 +      }
 +
 +      // If only 2 tokens were scanned, then an initial response was not provided.
 +      if n == 2 && conn.line[len(conn.line)-1] != ' ' {
 +              conn.reply(ReplyBadSyntax)
 +              return
 +      }
 +
 +      conn.log.Info("doAUTH()")
 +
 +      if authString == "" {
 +              conn.writeReply(334, " ")
 +
 +              authString, err = conn.tp.ReadLine()
 +              if err != nil {
 +                      conn.log.Error("failed to read auth line", zap.Error(err))
 +                      conn.reply(ReplyBadSyntax)
 +                      return
 +              }
 +      }
 +
 +      authBytes, err := base64.StdEncoding.DecodeString(authString)
 +      if err != nil {
 +              conn.reply(ReplyBadSyntax)
 +              return
 +      }
 +
 +      authParts := strings.Split(string(authBytes), "\x00")
 +      if len(authParts) != 3 {
 +              conn.log.Error("bad auth line syntax")
 +              conn.reply(ReplyBadSyntax)
 +              return
 +      }
 +
 +      if !conn.server.Authenticate(authParts[0], authParts[1], authParts[2]) {
 +              conn.log.Error("failed to authenticate", zap.String("authc", authParts[1]))
 +              conn.writeReply(535, "invalid credentials")
 +              return
 +      }
 +
 +      conn.log.Info("authenticated", zap.String("authz", authParts[0]), zap.String("authc", authParts[1]))
 +      conn.authc = authParts[1]
 +      conn.reply(ReplyOK)
 +}
 +
  func (conn *connection) doMAIL() {
        if conn.state != stateInitial {
                conn.reply(ReplyBadSequence)
                return
        }
  
 +      if conn.server.VerifyAddress(*conn.mailFrom) == ReplyOK {
 +              if DomainForAddress(*conn.mailFrom) != DomainForAddressString(conn.authc) {
 +                      conn.writeReply(550, "not authenticated")
 +                      return
 +              }
 +              conn.delivery = deliverOutbound
 +      } else {
 +              conn.delivery = deliverInbound
 +      }
 +
        conn.log.Info("doMAIL()", zap.String("address", conn.mailFrom.Address))
  
        conn.state = stateMail
@@@ -363,7 -253,7 +369,7 @@@ func (conn *connection) doRCPT() 
                return
        }
  
 -      if reply := conn.server.VerifyAddress(*address); reply != ReplyOK {
 +      if reply := conn.server.VerifyAddress(*address); reply != ReplyOK && conn.delivery == deliverInbound {
                conn.log.Warn("invalid address",
                        zap.String("address", address.Address),
                        zap.Stringer("reply", reply))
                return
        }
  
 -      conn.log.Info("doRCPT()", zap.String("address", address.Address))
 +      conn.log.Info("doRCPT()",
 +              zap.String("address", address.Address),
 +              zap.String("delivery", conn.delivery.String()))
  
        conn.rcptTo = append(conn.rcptTo, *address)
  
@@@ -407,103 -295,27 +413,103 @@@ func (conn *connection) doDATA() 
                RcptTo:     conn.rcptTo,
                Received:   received,
                ID:         conn.envelopeID(received),
 +              Data:       data,
        }
  
 +      conn.handleSendAs(&env)
 +
        conn.log.Info("received message",
                zap.Int("bytes", len(data)),
                zap.Time("date", received),
 -              zap.String("id", env.ID))
 +              zap.String("id", env.ID),
 +              zap.String("delivery", conn.delivery.String()))
  
        trace := conn.getReceivedInfo(env)
  
 -      env.Data = append(trace, data...)
 +      env.Data = append(trace, env.Data...)
  
 -      if reply := conn.server.OnMessageDelivered(env); reply != nil {
 -              conn.log.Warn("message was rejected", zap.String("id", env.ID))
 -              conn.reply(*reply)
 -              return
 +      if conn.delivery == deliverInbound {
 +              if reply := conn.server.OnMessageDelivered(env); reply != nil {
 +                      conn.log.Warn("message was rejected", zap.String("id", env.ID))
 +                      conn.reply(*reply)
 +                      return
 +              }
 +      } else if conn.delivery == deliverOutbound {
 +              conn.server.RelayMessage(env)
        }
  
        conn.state = stateInitial
 +      conn.resetBuffers()
        conn.reply(ReplyOK)
  }
  
 +func (conn *connection) handleSendAs(env *Envelope) {
 +      if conn.delivery != deliverOutbound {
 +              return
 +      }
 +
 +      // Find the separator between the message header and body.
 +      headerIdx := bytes.Index(env.Data, []byte("\n\n"))
 +      if headerIdx == -1 {
 +              conn.log.Error("send-as: could not find headers index")
 +              return
 +      }
 +
 +      var buf bytes.Buffer
 +
 +      headers := bytes.SplitAfter(env.Data[:headerIdx], []byte("\n"))
 +
 +      var fromIdx, subjectIdx int
 +      for i, header := range headers {
 +              if bytes.HasPrefix(header, []byte("From:")) {
 +                      fromIdx = i
 +                      continue
 +              }
 +              if bytes.HasPrefix(header, []byte("Subject:")) {
 +                      subjectIdx = i
 +                      continue
 +              }
 +      }
 +
 +      if subjectIdx == -1 {
 +              conn.log.Error("send-as: could not find Subject header")
 +              return
 +      }
 +      if fromIdx == -1 {
 +              conn.log.Error("send-as: could not find From header")
 +              return
 +      }
 +
 +      sendAs := SendAsSubject.FindSubmatchIndex(headers[subjectIdx])
 +      if sendAs == nil {
 +              // No send-as modification.
 +              return
 +      }
 +
 +      // Submatch 0 is the whole sendas magic. Submatch 1 is the address prefix.
 +      sendAsUser := headers[subjectIdx][sendAs[2]:sendAs[3]]
 +      sendAsAddress := string(sendAsUser) + "@" + DomainForAddressString(conn.authc)
 +
 +      for i, header := range headers {
 +              if i == subjectIdx {
 +                      buf.Write(header[:sendAs[0]])
 +                      buf.Write(header[sendAs[1]:])
 +              } else if i == fromIdx {
 +                      addressStart := bytes.LastIndexByte(header, byte('<'))
 +                      buf.Write(header[:addressStart+1])
 +                      buf.WriteString(sendAsAddress)
 +                      buf.WriteString(">\n")
 +              } else {
 +                      buf.Write(header)
 +              }
 +      }
 +
 +      buf.Write(env.Data[headerIdx:])
 +
 +      env.Data = buf.Bytes()
 +      env.MailFrom.Address = sendAsAddress
 +}
 +
  func (conn *connection) envelopeID(t time.Time) string {
        var idBytes [4]byte
        rand.Read(idBytes[:])
@@@ -532,9 -344,7 +538,9 @@@ func (conn *connection) getReceivedInfo
        }
        base += fmt.Sprintf("by %s (mailpopbox) with %s id %s\r\n        ", conn.server.Name(), with, envelope.ID)
  
 -      base += fmt.Sprintf("for <%s>\r\n        ", envelope.RcptTo[0].Address)
 +      if len(envelope.RcptTo) > 0 {
 +              base += fmt.Sprintf("for <%s>\r\n        ", envelope.RcptTo[0].Address)
 +      }
  
        transport := conn.getTransportString()
        date := envelope.Received.Format(time.RFC1123Z) // Same as RFC 5322 ยง 3.3
@@@ -602,8 -412,6 +608,8 @@@ func (conn *connection) doRSET() 
  }
  
  func (conn *connection) resetBuffers() {
 +      conn.delivery = deliverUnknown
 +      conn.sendAs = nil
        conn.mailFrom = nil
        conn.rcptTo = make([]mail.Address, 0)
  }
diff --combined smtp/conn_test.go
index 72b559259aa8a805d98d977eb702eec02cd3e76c,84fda8a12338b2c6f6bbd1adbc9a7ede8e5d6144..e1e1501d8e67a921317de649567c232805a13708
@@@ -1,8 -1,12 +1,14 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package smtp
  
  import (
 +      "crypto/tls"
 +      "encoding/base64"
        "fmt"
        "net"
        "net/mail"
@@@ -13,7 -17,7 +19,7 @@@
        "testing"
        "time"
  
-       "github.com/uber-go/zap"
+       "go.uber.org/zap"
  )
  
  func _fl(depth int) string {
@@@ -28,9 -32,9 +34,9 @@@ func ok(t testing.TB, err error) 
  }
  
  func readCodeLine(t testing.TB, conn *textproto.Conn, code int) string {
 -      _, message, err := conn.ReadCodeLine(code)
 +      actual, message, err := conn.ReadCodeLine(code)
        if err != nil {
 -              t.Errorf("%s ReadCodeLine error: %v", _fl(1), err)
 +              t.Errorf("%s ReadCodeLine error, expected %d, got %d: %v", _fl(1), code, actual, err)
        }
        return message
  }
@@@ -50,38 -54,23 +56,38 @@@ func runServer(t *testing.T, server Ser
                        if err != nil {
                                return
                        }
-                       go AcceptConnection(conn, server, zap.New(zap.NullEncoder()))
+                       go AcceptConnection(conn, server, zap.NewNop())
                }
        }()
  
        return l
  }
  
 +type userAuth struct {
 +      authz, authc, passwd string
 +}
 +
  type testServer struct {
        EmptyServerCallbacks
 +      domain    string
        blockList []string
 +      tlsConfig *tls.Config
 +      *userAuth
 +      relayed []Envelope
  }
  
  func (s *testServer) Name() string {
        return "Test-Server"
  }
  
 +func (s *testServer) TLSConfig() *tls.Config {
 +      return s.tlsConfig
 +}
 +
  func (s *testServer) VerifyAddress(addr mail.Address) ReplyLine {
 +      if DomainForAddress(addr) != s.domain {
 +              return ReplyBadMailbox
 +      }
        for _, block := range s.blockList {
                if strings.ToLower(block) == addr.Address {
                        return ReplyBadMailbox
        return ReplyOK
  }
  
 +func (s *testServer) Authenticate(authz, authc, passwd string) bool {
 +      return s.userAuth.authz == authz &&
 +              s.userAuth.authc == authc &&
 +              s.userAuth.passwd == passwd
 +}
 +
 +func (s *testServer) RelayMessage(en Envelope) {
 +      s.relayed = append(s.relayed, en)
 +}
 +
  func createClient(t *testing.T, addr net.Addr) *textproto.Conn {
        conn, err := textproto.Dial(addr.Network(), addr.String())
        if err != nil {
@@@ -117,22 -96,19 +123,22 @@@ type requestResponse struct 
  
  func runTableTest(t testing.TB, conn *textproto.Conn, seq []requestResponse) {
        for i, rr := range seq {
 -              t.Logf("%s case %d", _fl(1), i)
                ok(t, conn.PrintfLine(rr.request))
                if rr.handler != nil {
                        rr.handler(t, conn)
                } else {
                        readCodeLine(t, conn, rr.responseCode)
                }
 +              if t.Failed() {
 +                      t.Logf("%s case %d", _fl(1), i)
 +              }
        }
  }
  
  // RFC 5321 ยง D.1
  func TestScenarioTypical(t *testing.T) {
        s := testServer{
 +              domain:    "foo.com",
                blockList: []string{"Green@foo.com"},
        }
        l := runServer(t, &s)
  
  func TestVerifyAddress(t *testing.T) {
        s := testServer{
 +              domain:    "test.mail",
                blockList: []string{"banned@test.mail"},
        }
        l := runServer(t, &s)
@@@ -216,10 -191,8 +222,10 @@@ func TestBadAddress(t *testing.T) 
  }
  
  func TestCaseSensitivty(t *testing.T) {
 -      s := &testServer{}
 -      s.blockList = []string{"reject@mail.com"}
 +      s := &testServer{
 +              domain:    "mail.com",
 +              blockList: []string{"reject@mail.com"},
 +      }
        l := runServer(t, s)
        defer l.Close()
  
@@@ -308,297 -281,3 +314,297 @@@ func TestGetReceivedInfo(t *testing.T) 
        }
  
  }
 +
 +func getTLSConfig(t *testing.T) *tls.Config {
 +      cert, err := tls.LoadX509KeyPair("../testtls/domain.crt", "../testtls/domain.key")
 +      if err != nil {
 +              t.Fatal(err)
 +              return nil
 +      }
 +      return &tls.Config{
 +              ServerName:         "localhost",
 +              Certificates:       []tls.Certificate{cert},
 +              InsecureSkipVerify: true,
 +      }
 +}
 +
 +func setupTLSClient(t *testing.T, addr net.Addr) *textproto.Conn {
 +      nc, err := net.Dial(addr.Network(), addr.String())
 +      ok(t, err)
 +
 +      conn := textproto.NewConn(nc)
 +      readCodeLine(t, conn, 220)
 +
 +      ok(t, conn.PrintfLine("EHLO test-tls"))
 +      _, resp, err := conn.ReadResponse(250)
 +      ok(t, err)
 +      if !strings.Contains(resp, "STARTTLS\n") {
 +              t.Errorf("STARTTLS not advertised")
 +      }
 +
 +      ok(t, conn.PrintfLine("STARTTLS"))
 +      readCodeLine(t, conn, 220)
 +
 +      tc := tls.Client(nc, getTLSConfig(t))
 +      err = tc.Handshake()
 +      ok(t, err)
 +
 +      conn = textproto.NewConn(tc)
 +
 +      ok(t, conn.PrintfLine("EHLO test-tls-started"))
 +      _, resp, err = conn.ReadResponse(250)
 +      ok(t, err)
 +      if strings.Contains(resp, "STARTTLS\n") {
 +              t.Errorf("STARTTLS advertised when already started")
 +      }
 +
 +      return conn
 +}
 +
 +func b64enc(s string) string {
 +      return string(base64.StdEncoding.EncodeToString([]byte(s)))
 +}
 +
 +func TestTLS(t *testing.T) {
 +      l := runServer(t, &testServer{tlsConfig: getTLSConfig(t)})
 +      defer l.Close()
 +
 +      setupTLSClient(t, l.Addr())
 +}
 +
 +func TestAuthWithoutTLS(t *testing.T) {
 +      l := runServer(t, &testServer{})
 +      defer l.Close()
 +
 +      conn := createClient(t, l.Addr())
 +      readCodeLine(t, conn, 220)
 +
 +      ok(t, conn.PrintfLine("EHLO test"))
 +      _, resp, err := conn.ReadResponse(250)
 +      ok(t, err)
 +
 +      if strings.Contains(resp, "AUTH") {
 +              t.Errorf("AUTH should not be advertised over plaintext")
 +      }
 +}
 +
 +func TestAuth(t *testing.T) {
 +      l := runServer(t, &testServer{
 +              tlsConfig: getTLSConfig(t),
 +              userAuth: &userAuth{
 +                      authz:  "-authz-",
 +                      authc:  "-authc-",
 +                      passwd: "goats",
 +              },
 +      })
 +      defer l.Close()
 +
 +      conn := setupTLSClient(t, l.Addr())
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"AUTH", 501, nil},
 +              {"AUTH OAUTHBEARER", 504, nil},
 +              {"AUTH PLAIN", 501, nil}, // Bad syntax, missing space.
 +              {"AUTH PLAIN ", 334, nil},
 +              {b64enc("abc\x00def\x00ghf"), 535, nil},
 +              {"AUTH PLAIN ", 334, nil},
 +              {b64enc("\x00"), 501, nil},
 +              {"AUTH PLAIN ", 334, nil},
 +              {"this isn't base 64", 501, nil},
 +              {"AUTH PLAIN ", 334, nil},
 +              {b64enc("-authz-\x00-authc-\x00goats"), 250, nil},
 +              {"AUTH PLAIN ", 503, nil}, // Already authenticated.
 +              {"NOOP", 250, nil},
 +      })
 +}
 +
 +func TestAuthNoInitialResponse(t *testing.T) {
 +      l := runServer(t, &testServer{
 +              tlsConfig: getTLSConfig(t),
 +              userAuth: &userAuth{
 +                      authz:  "",
 +                      authc:  "user",
 +                      passwd: "longpassword",
 +              },
 +      })
 +      defer l.Close()
 +
 +      conn := setupTLSClient(t, l.Addr())
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"AUTH PLAIN " + b64enc("\x00user\x00longpassword"), 250, nil},
 +      })
 +}
 +
 +func TestRelayRequiresAuth(t *testing.T) {
 +      l := runServer(t, &testServer{
 +              domain:    "example.com",
 +              tlsConfig: getTLSConfig(t),
 +              userAuth: &userAuth{
 +                      authz:  "",
 +                      authc:  "mailbox@example.com",
 +                      passwd: "test",
 +              },
 +      })
 +      defer l.Close()
 +
 +      conn := setupTLSClient(t, l.Addr())
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"MAIL FROM:<apples@example.com>", 550, nil},
 +              {"MAIL FROM:<mailbox@example.com>", 550, nil},
 +              {"AUTH PLAIN ", 334, nil},
 +              {b64enc("\x00mailbox@example.com\x00test"), 250, nil},
 +              {"MAIL FROM:<mailbox@example.com>", 250, nil},
 +      })
 +}
 +
 +func setupRelayTest(t *testing.T) (server *testServer, l net.Listener, conn *textproto.Conn) {
 +      server = &testServer{
 +              domain:    "example.com",
 +              tlsConfig: getTLSConfig(t),
 +              userAuth: &userAuth{
 +                      authz:  "",
 +                      authc:  "mailbox@example.com",
 +                      passwd: "test",
 +              },
 +      }
 +      l = runServer(t, server)
 +      conn = setupTLSClient(t, l.Addr())
 +      runTableTest(t, conn, []requestResponse{
 +              {"AUTH PLAIN ", 334, nil},
 +              {b64enc("\x00mailbox@example.com\x00test"), 250, nil},
 +      })
 +      return
 +}
 +
 +func TestBasicRelay(t *testing.T) {
 +      server, l, conn := setupRelayTest(t)
 +      defer l.Close()
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"MAIL FROM:<mailbox@example.com>", 250, nil},
 +              {"RCPT TO:<dest@another.net>", 250, nil},
 +              {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
 +                      readCodeLine(t, conn, 354)
 +
 +                      ok(t, conn.PrintfLine("From: <mailbox@example.com>"))
 +                      ok(t, conn.PrintfLine("To: <dest@example.com>"))
 +                      ok(t, conn.PrintfLine("Subject: Basic relay\n"))
 +                      ok(t, conn.PrintfLine("This is a basic relay message"))
 +                      ok(t, conn.PrintfLine("."))
 +                      readCodeLine(t, conn, 250)
 +              }},
 +      })
 +
 +      if len(server.relayed) != 1 {
 +              t.Errorf("Expected 1 relayed message, got %d", len(server.relayed))
 +      }
 +}
 +
 +func TestSendAsRelay(t *testing.T) {
 +      server, l, conn := setupRelayTest(t)
 +      defer l.Close()
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"MAIL FROM:<mailbox@example.com>", 250, nil},
 +              {"RCPT TO:<valid@dest.xyz>", 250, nil},
 +              {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
 +                      readCodeLine(t, conn, 354)
 +
 +                      ok(t, conn.PrintfLine("From: <mailbox@example.com>"))
 +                      ok(t, conn.PrintfLine("To: <valid@dest.xyz>"))
 +                      ok(t, conn.PrintfLine("Subject: Send-as relay [sendas:source]\n"))
 +                      ok(t, conn.PrintfLine("We've switched the senders!"))
 +                      ok(t, conn.PrintfLine("."))
 +                      readCodeLine(t, conn, 250)
 +              }},
 +      })
 +
 +      if len(server.relayed) != 1 {
 +              t.Fatalf("Expected 1 relayed message, got %d", len(server.relayed))
 +      }
 +
 +      replaced := "source@example.com"
 +      original := "mailbox@example.com"
 +
 +      en := server.relayed[0]
 +      if en.MailFrom.Address != replaced {
 +              t.Errorf("Expected mail to be from %q, got %q", replaced, en.MailFrom.Address)
 +      }
 +
 +      if len(en.RcptTo) != 1 {
 +              t.Errorf("Expected 1 recipient, got %d", len(en.RcptTo))
 +      }
 +      if en.RcptTo[0].Address != "valid@dest.xyz" {
 +              t.Errorf("Unexpected RcptTo %q", en.RcptTo[0].Address)
 +      }
 +
 +      msg := string(en.Data)
 +
 +      if strings.Index(msg, original) != -1 {
 +              t.Errorf("Should not find %q in message %q", original, msg)
 +      }
 +
 +      if strings.Index(msg, "\nFrom: <source@example.com>\n") == -1 {
 +              t.Errorf("Could not find From: header in message %q", msg)
 +      }
 +
 +      if strings.Index(msg, "\nSubject: Send-as relay \n") == -1 {
 +              t.Errorf("Could not find modified Subject: header in message %q", msg)
 +      }
 +}
 +
 +func TestSendMultipleRelay(t *testing.T) {
 +      server, l, conn := setupRelayTest(t)
 +      defer l.Close()
 +
 +      runTableTest(t, conn, []requestResponse{
 +              {"MAIL FROM:<mailbox@example.com>", 250, nil},
 +              {"RCPT TO:<valid@dest.xyz>", 250, nil},
 +              {"RCPT TO:<another@dest.org>", 250, nil},
 +              {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
 +                      readCodeLine(t, conn, 354)
 +
 +                      ok(t, conn.PrintfLine("To: Cindy <valid@dest.xyz>, Sam <another@dest.org>"))
 +                      ok(t, conn.PrintfLine("From: Finn <mailbox@example.com>"))
 +                      ok(t, conn.PrintfLine("Subject: Two destinations [sendas:source]\n"))
 +                      ok(t, conn.PrintfLine("And we've switched the senders!"))
 +                      ok(t, conn.PrintfLine("."))
 +                      readCodeLine(t, conn, 250)
 +              }},
 +      })
 +
 +      if len(server.relayed) != 1 {
 +              t.Fatalf("Expected 1 relayed message, got %d", len(server.relayed))
 +      }
 +
 +      replaced := "source@example.com"
 +      original := "mailbox@example.com"
 +
 +      en := server.relayed[0]
 +      if en.MailFrom.Address != replaced {
 +              t.Errorf("Expected mail to be from %q, got %q", replaced, en.MailFrom.Address)
 +      }
 +
 +      if len(en.RcptTo) != 2 {
 +              t.Errorf("Expected 2 recipient, got %d", len(en.RcptTo))
 +      }
 +      if en.RcptTo[0].Address != "valid@dest.xyz" {
 +              t.Errorf("Unexpected RcptTo %q", en.RcptTo[0].Address)
 +      }
 +
 +      msg := string(en.Data)
 +
 +      if strings.Index(msg, original) != -1 {
 +              t.Errorf("Should not find %q in message %q", original, msg)
 +      }
 +
 +      if strings.Index(msg, "\nFrom: Finn <source@example.com>\n") == -1 {
 +              t.Errorf("Could not find From: header in message %q", msg)
 +      }
 +
 +      if strings.Index(msg, "\nSubject: Two destinations \n") == -1 {
 +              t.Errorf("Could not find modified Subject: header in message %q", msg)
 +      }
 +}
diff --combined smtp/server.go
index abbd4f7b86932517a4cb75af6f597194f1924ed9,547fb33df38f197744e46fafe6acc730df45dde2..14002c5be2ba28793fa719edda77b58a47f5bfc4
@@@ -1,13 -1,17 +1,19 @@@
+ // mailpopbox
+ // Copyright 2020 Blue Static <https://www.bluestatic.org>
+ // This program is free software licensed under the GNU General Public License,
+ // version 3.0. The full text of the license can be found in LICENSE.txt.
+ // SPDX-License-Identifier: GPL-3.0-only
  package smtp
  
  import (
        "crypto/tls"
 +      "regexp"
        "fmt"
        "io"
        "net"
        "net/mail"
 +      "strings"
        "time"
  )
  
@@@ -20,28 -24,13 +26,28 @@@ func (l ReplyLine) String() string 
        return fmt.Sprintf("%d %s", l.Code, l.Message)
  }
  
 +var SendAsSubject = regexp.MustCompile(`(?i)\[sendas:\s*([a-zA-Z0-9\.\-_]+)\]`)
 +
  var (
 -      ReplyOK          = ReplyLine{250, "OK"}
 -      ReplyBadSyntax   = ReplyLine{501, "syntax error"}
 -      ReplyBadSequence = ReplyLine{503, "bad sequence of commands"}
 -      ReplyBadMailbox  = ReplyLine{550, "mailbox unavailable"}
 +      ReplyOK               = ReplyLine{250, "OK"}
 +      ReplyBadSyntax        = ReplyLine{501, "syntax error"}
 +      ReplyBadSequence      = ReplyLine{503, "bad sequence of commands"}
 +      ReplyBadMailbox       = ReplyLine{550, "mailbox unavailable"}
 +      ReplyMailboxUnallowed = ReplyLine{553, "mailbox name not allowed"}
  )
  
 +func DomainForAddress(addr mail.Address) string {
 +      return DomainForAddressString(addr.Address)
 +}
 +
 +func DomainForAddressString(address string) string {
 +      domainIdx := strings.LastIndex(address, "@")
 +      if domainIdx == -1 {
 +              return ""
 +      }
 +      return address[domainIdx+1:]
 +}
 +
  type Envelope struct {
        RemoteAddr net.Addr
        EHLO       string
@@@ -61,14 -50,9 +67,14 @@@ func WriteEnvelopeForDelivery(w io.Writ
  type Server interface {
        Name() string
        TLSConfig() *tls.Config
 -      OnEHLO() *ReplyLine
        VerifyAddress(mail.Address) ReplyLine
 +      // Verify that the authc+passwd identity can send mail as authz.
 +      Authenticate(authz, authc, passwd string) bool
        OnMessageDelivered(Envelope) *ReplyLine
 +
 +      // RelayMessage instructs the server to send the Envelope to another
 +      // MTA for outbound delivery.
 +      RelayMessage(Envelope)
  }
  
  type EmptyServerCallbacks struct{}
@@@ -77,17 -61,14 +83,17 @@@ func (*EmptyServerCallbacks) TLSConfig(
        return nil
  }
  
 -func (*EmptyServerCallbacks) OnEHLO() *ReplyLine {
 -      return nil
 -}
 -
  func (*EmptyServerCallbacks) VerifyAddress(mail.Address) ReplyLine {
        return ReplyOK
  }
  
 +func (*EmptyServerCallbacks) Authenticate(authz, authc, passwd string) bool {
 +      return false
 +}
 +
  func (*EmptyServerCallbacks) OnMessageDelivered(Envelope) *ReplyLine {
        return nil
  }
 +
 +func (*EmptyServerCallbacks) RelayMessage(Envelope) {
 +}