Add support for outbound SMTP delivery.
authorRobert Sesek <rsesek@bluestatic.org>
Thu, 30 Apr 2020 02:17:03 +0000 (22:17 -0400)
committerRobert Sesek <rsesek@bluestatic.org>
Thu, 30 Apr 2020 02:17:03 +0000 (22:17 -0400)
Messages can be sent through the SMTP server to other MTAs. In a reverse
of the catch-all inbound mail server, a message can be made to appear
From any address at the domain by BCCing <sameas+ANYTHING@DOMAIN> on the
message.

README.md
smtp.go
smtp/conn.go
smtp/conn_test.go
smtp/relay.go [new file with mode: 0644]
smtp/server.go

index 03a343f7f6ed3aa3a15db331754b30c087ce59d1..da6ae6c9d5d898a64288225a4c245783f13ca182 100644 (file)
--- a/README.md
+++ b/README.md
@@ -9,12 +9,27 @@ single mailbox, which can then be accessed using the POP3 protocol.
 TLS is recommended in production environments. To facilitate live-reloading of certificates, you can
 send a running instance SIGHUP.
 
+## Send-As SMTP
+
+Since mailpopbox is designed as a catch-all mail server, it would be impractical to administer SMTP
+accounts to enable replying from any address handled by the server. The SMTP server instead
+provides a way to send messages from arbitrary addresses by authenticating as the mailbox@DOMAIN
+user. Any valid SMTP MAIL FROM is supported after authentication, but mail clients will typically
+use the mailbox@DOMAIN user or the From header. The SMTP server's feature is that if the message has
+a SMTP RCPT TO an address sendas+ANYTHING@DOMAIN, the server will alter the From message header to
+be ANYTHING@DOMAIN.
+
+Practically, this means configuring an outbound mail client to send mail as mailbox@DOMAIN and
+authenticate to the SMTP server as such. And in order to change the sending address as perceived by
+the recipient, BCC sendas+ANYTHING@DOMAIN, so the resulting message is from ANYTHING@DOMAIN.
+
 ## RFCs
 
 This server implements the following RFCs:
 
 - [Post Office Protocol - Version 3, RFC 1939](https://tools.ietf.org/html/rfc1939)
 - [Simple Mail Transfer Protocol, RFC 5321](https://tools.ietf.org/html/rfc5321)
+- [Message Submission for Mail, RFC 6409](https://tools.ietf.org/html/rfc6409)
 - [SMTP Service Extension for Secure SMTP over Transport Layer Security, RFC 3207](https://tools.ietf.org/html/rfc3207)
 - [SMTP Service Extension for Authentication, RFC 2554](https://tools.ietf.org/html/rfc2554)
 - [The PLAIN Simple Authentication and Security Layer (SASL) Mechanism, RFC 4616](https://tools.ietf.org/html/rfc4616)
diff --git a/smtp.go b/smtp.go
index 572e1f63bbbfec63cd47c2bee363986ecb527415..2bbb15460f28c98fc9e2bbdfce93028a7e5bf2fb 100644 (file)
--- a/smtp.go
+++ b/smtp.go
@@ -137,6 +137,11 @@ func (server *smtpServer) OnMessageDelivered(en smtp.Envelope) *smtp.ReplyLine {
        return nil
 }
 
+func (server *smtpServer) RelayMessage(en smtp.Envelope) {
+       log := server.log.With(zap.String("id", en.ID))
+       go smtp.RelayMessage(en, log)
+}
+
 func (server *smtpServer) maildropForAddress(addr mail.Address) string {
        domain := smtp.DomainForAddress(addr)
        for _, s := range server.config.Servers {
index fd133d79c6bc1c6178b8c21bda4fff184da9b82c..11995b14e4e7bc9cf49297fa03184bcadf723118 100644 (file)
@@ -1,6 +1,7 @@
 package smtp
 
 import (
+       "bytes"
        "crypto/rand"
        "crypto/tls"
        "encoding/base64"
@@ -24,6 +25,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
 
@@ -44,6 +65,10 @@ type connection struct {
        state
        line string
 
+       delivery
+       // For deliverOutbound, replaces the From and Reply-To values.
+       sendAs *mail.Address
+
        ehlo     string
        mailFrom *mail.Address
        rcptTo   []mail.Address
@@ -291,6 +316,20 @@ func (conn *connection) doMAIL() {
                return
        }
 
+       if conn.server.VerifyAddress(*conn.mailFrom) == ReplyOK {
+               // Message is being sent from a domain that this is an MTA for. Ultimate
+               // handling of the outbound message requires knowing the recipient.
+               domain := DomainForAddress(*conn.mailFrom)
+               // TODO: better way to authenticate this?
+               if !strings.HasSuffix(conn.authc, "@"+domain) {
+                       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
@@ -315,15 +354,52 @@ func (conn *connection) doRCPT() {
                return
        }
 
-       if reply := conn.server.VerifyAddress(*address); reply != ReplyOK {
-               conn.log.Warn("invalid address",
-                       zap.String("address", address.Address),
-                       zap.Stringer("reply", reply))
-               conn.reply(reply)
-               return
+       if reply := conn.server.VerifyAddress(*address); reply == ReplyOK {
+               // Message is addressed to this server. If it's outbound, only support
+               // the special send-as addressing.
+               if conn.delivery == deliverOutbound {
+                       if !strings.HasPrefix(address.Address, SendAsAddress) {
+                               conn.log.Error("internal relay addressing not supported",
+                                       zap.String("address", address.Address))
+                               conn.reply(ReplyBadMailbox)
+                               return
+                       }
+                       address.Address = strings.TrimPrefix(address.Address, SendAsAddress)
+                       if DomainForAddress(*address) != DomainForAddressString(conn.authc) {
+                               conn.log.Error("not authenticated for send-as",
+                                       zap.String("address", address.Address),
+                                       zap.String("authc", conn.authc))
+                               conn.reply(ReplyBadMailbox)
+                               return
+                       }
+                       if conn.sendAs != nil {
+                               conn.log.Error("sendAs already specified",
+                                       zap.String("address", address.Address),
+                                       zap.String("sendAs", conn.sendAs.Address))
+                               conn.reply(ReplyMailboxUnallowed)
+                               return
+                       }
+                       conn.log.Info("doRCPT()",
+                               zap.String("sendAs", address.Address))
+                       conn.sendAs = address
+                       conn.state = stateRecipient
+                       conn.reply(ReplyOK)
+                       return
+               }
+       } else {
+               // Message is not addressed to this server, so the delivery must be outbound.
+               if conn.delivery == deliverInbound {
+                       conn.log.Warn("invalid address",
+                               zap.String("address", address.Address),
+                               zap.Stringer("reply", reply))
+                       conn.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)
 
@@ -349,6 +425,8 @@ func (conn *connection) doDATA() {
                return
        }
 
+       conn.handleSendAs(&data)
+
        received := time.Now()
        env := Envelope{
                RemoteAddr: conn.remoteAddr,
@@ -362,22 +440,70 @@ func (conn *connection) doDATA() {
        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...)
 
-       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(data *[]byte) {
+       if conn.delivery != deliverOutbound || conn.sendAs == nil {
+               return
+       }
+
+       conn.mailFrom = conn.sendAs
+
+       // Find the separator between the message header and body.
+       headerIdx := bytes.Index(*data, []byte("\n\n"))
+       if headerIdx == -1 {
+               conn.log.Error("send-as: could not find headers index")
+               return
+       }
+
+       fromPrefix := []byte("From: ")
+       fromIdx := bytes.Index(*data, fromPrefix)
+       if fromIdx == -1 || fromIdx >= headerIdx {
+               conn.log.Error("send-as: could not find From header")
+               return
+       }
+       if fromIdx != 0 {
+               if (*data)[fromIdx-1] != '\n' {
+                       conn.log.Error("send-as: could not find From header")
+                       return
+               }
+       }
+
+       fromEndIdx := bytes.IndexByte((*data)[fromIdx:], '\n')
+       if fromIdx == -1 {
+               conn.log.Error("send-as: could not find end of From header")
+               return
+       }
+       fromEndIdx += fromIdx
+
+       newData := (*data)[:fromIdx]
+       newData = append(newData, fromPrefix...)
+       newData = append(newData, []byte(conn.sendAs.String())...)
+       newData = append(newData, (*data)[fromEndIdx:]...)
+
+       *data = newData
+}
+
 func (conn *connection) envelopeID(t time.Time) string {
        var idBytes [4]byte
        rand.Read(idBytes[:])
@@ -474,6 +600,8 @@ func (conn *connection) doRSET() {
 }
 
 func (conn *connection) resetBuffers() {
+       conn.delivery = deliverUnknown
+       conn.sendAs = nil
        conn.mailFrom = nil
        conn.rcptTo = make([]mail.Address, 0)
 }
index 70839079e5379ba40bdf30f301b7c14b848b8578..27dd63f60e7737255a9706a7b345515f06cb1e0e 100644 (file)
@@ -67,6 +67,7 @@ type testServer struct {
        blockList []string
        tlsConfig *tls.Config
        *userAuth
+       relayed []Envelope
 }
 
 func (s *testServer) Name() string {
@@ -95,6 +96,10 @@ func (s *testServer) Authenticate(authz, authc, passwd string) bool {
                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 {
@@ -348,6 +353,10 @@ func setupTLSClient(t *testing.T, addr net.Addr) *textproto.Conn {
        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()
@@ -384,10 +393,6 @@ func TestAuth(t *testing.T) {
 
        conn := setupTLSClient(t, l.Addr())
 
-       b64enc := func(s string) string {
-               return string(base64.StdEncoding.EncodeToString([]byte(s)))
-       }
-
        runTableTest(t, conn, []requestResponse{
                {"AUTH", 501, nil},
                {"AUTH OAUTHBEARER", 504, nil},
@@ -403,3 +408,183 @@ func TestAuth(t *testing.T) {
                {"NOOP", 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 TestNoInternalRelays(t *testing.T) {
+       _, 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:<dest@example.com>", 550, nil},
+               {"RCPT TO:<mailbox@example.com>", 550, nil},
+       })
+}
+
+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},
+               {"RCPT TO:<sendas+source@example.com>", 250, nil},
+               {"RCPT TO:<mailbox@example.com>", 550, 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\n"))
+                       ok(t, conn.PrintfLine("We've switched the senders!"))
+                       ok(t, conn.PrintfLine("."))
+                       readCodeLine(t, conn, 250)
+               }},
+       })
+
+       if len(server.relayed) != 1 {
+               t.Errorf("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)
+       }
+}
+
+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:<sendas+source@example.com>", 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: <mailbox@example.com>"))
+                       ok(t, conn.PrintfLine("Subject: Two destinations\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.Errorf("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: <source@example.com>\n") == -1 {
+               t.Errorf("Could not find From: header in message %q", msg)
+       }
+}
diff --git a/smtp/relay.go b/smtp/relay.go
new file mode 100644 (file)
index 0000000..23333e5
--- /dev/null
@@ -0,0 +1,49 @@
+// 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 (
+       "net"
+       "net/smtp"
+
+       "github.com/uber-go/zap"
+)
+
+func RelayMessage(env Envelope, log zap.Logger) {
+       for _, rcptTo := range env.RcptTo {
+               domain := DomainForAddress(rcptTo)
+               mx, err := net.LookupMX(domain)
+               if err != nil || len(mx) < 1 {
+                       log.Error("failed to lookup MX records",
+                               zap.String("address", rcptTo.Address),
+                               zap.Error(err))
+                       deliverRelayFailure(env, err)
+                       return
+               }
+
+               to := []string{rcptTo.Address}
+               from := env.MailFrom.Address
+               host := mx[0].Host + ":25"
+
+               log.Info("relay message",
+                       zap.String("to", to[0]),
+                       zap.String("from", from),
+                       zap.String("server", host))
+               err = smtp.SendMail(host, nil, from, to, env.Data)
+               if err != nil {
+                       log.Error("failed to relay message",
+                               zap.String("address", rcptTo.Address),
+                               zap.Error(err))
+                       deliverRelayFailure(env, err)
+                       return
+               }
+       }
+}
+
+func deliverRelayFailure(env Envelope, err error) {
+       // TODO: constructo a delivery status notification
+}
index f9ecb1d2110a9a86b8152040b3229dde560b3408..a83f58237fd5b041e31d30786a503d561585c5ce 100644 (file)
@@ -19,19 +19,26 @@ func (l ReplyLine) String() string {
        return fmt.Sprintf("%d %s", l.Code, l.Message)
 }
 
+const SendAsAddress = "sendas+"
+
 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 {
-       domainIdx := strings.LastIndex(addr.Address, "@")
+       return DomainForAddressString(addr.Address)
+}
+
+func DomainForAddressString(address string) string {
+       domainIdx := strings.LastIndex(address, "@")
        if domainIdx == -1 {
                return ""
        }
-       return addr.Address[domainIdx+1:]
+       return address[domainIdx+1:]
 }
 
 type Envelope struct {
@@ -57,6 +64,10 @@ type Server interface {
        // 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{}
@@ -76,3 +87,6 @@ func (*EmptyServerCallbacks) Authenticate(authz, authc, passwd string) bool {
 func (*EmptyServerCallbacks) OnMessageDelivered(Envelope) *ReplyLine {
        return nil
 }
+
+func (*EmptyServerCallbacks) RelayMessage(Envelope) {
+}