Change the send-as functionality to use a string in the Subject.
authorRobert Sesek <rsesek@bluestatic.org>
Thu, 30 Apr 2020 05:37:40 +0000 (01:37 -0400)
committerRobert Sesek <rsesek@bluestatic.org>
Thu, 30 Apr 2020 05:37:40 +0000 (01:37 -0400)
Using the RCPT TO for BCCs does not work if mailpopbox is not the
original MTA to accept the message. The originating MTA creates two
envelopes with 1 RCPT TO each, rather than 1 envelope with 2 RCPT TO.

Editing the subject breaks Gmail threading on the send-side, but it
works. An alternative was to search the body of the message for a
string, but that gets more complex with complex message Content-Types.

README.md
smtp/conn.go
smtp/conn_test.go
smtp/server.go

index da6ae6c9d5d898a64288225a4c245783f13ca182..05a0e51a4068668357ae0c037784ea9ff831d715 100644 (file)
--- a/README.md
+++ b/README.md
@@ -15,13 +15,13 @@ Since mailpopbox is designed as a catch-all mail server, it would be impractical
 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.
+use the mailbox@DOMAIN user or the From header. The SMTP server's feature is that if the message's
+Subject header has a special "[sendas:ANYTHING]" string, the server will alter the From message
+header to be from 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.
+the recipient, edit the subject with [sendas:ADDRESS].
 
 ## RFCs
 
index f1c195d220b0534df802b00097536d4ba823bb1d..eb868e4fd17ddf315b04af9b151e489bed423f5b 100644 (file)
@@ -330,11 +330,7 @@ func (conn *connection) doMAIL() {
        }
 
        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) {
+               if DomainForAddress(*conn.mailFrom) != DomainForAddressString(conn.authc) {
                        conn.writeReply(550, "not authenticated")
                        return
                }
@@ -367,47 +363,12 @@ func (conn *connection) doRCPT() {
                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
-               }
+       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))
+               conn.reply(reply)
+               return
        }
 
        conn.log.Info("doRCPT()",
@@ -438,8 +399,6 @@ func (conn *connection) doDATA() {
                return
        }
 
-       conn.handleSendAs(&data)
-
        received := time.Now()
        env := Envelope{
                RemoteAddr: conn.remoteAddr,
@@ -448,8 +407,11 @@ 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),
@@ -458,7 +420,7 @@ func (conn *connection) doDATA() {
 
        trace := conn.getReceivedInfo(env)
 
-       env.Data = append(trace, data...)
+       env.Data = append(trace, env.Data...)
 
        if conn.delivery == deliverInbound {
                if reply := conn.server.OnMessageDelivered(env); reply != nil {
@@ -475,46 +437,71 @@ func (conn *connection) doDATA() {
        conn.reply(ReplyOK)
 }
 
-func (conn *connection) handleSendAs(data *[]byte) {
-       if conn.delivery != deliverOutbound || conn.sendAs == nil {
+func (conn *connection) handleSendAs(env *Envelope) {
+       if conn.delivery != deliverOutbound {
                return
        }
 
-       conn.mailFrom = conn.sendAs
-
        // Find the separator between the message header and body.
-       headerIdx := bytes.Index(*data, []byte("\n\n"))
+       headerIdx := bytes.Index(env.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
+       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
                }
        }
 
-       fromEndIdx := bytes.IndexByte((*data)[fromIdx:], '\n')
+       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 end of From header")
+               conn.log.Error("send-as: could not find From header")
                return
        }
-       fromEndIdx += fromIdx
 
-       newData := (*data)[:fromIdx]
-       newData = append(newData, fromPrefix...)
-       newData = append(newData, []byte(conn.sendAs.String())...)
-       newData = append(newData, (*data)[fromEndIdx:]...)
+       sendAs := SendAsSubject.FindSubmatchIndex(headers[subjectIdx])
+       if sendAs == nil {
+               // No send-as modification.
+               return
+       }
 
-       *data = newData
+       // 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 {
@@ -545,7 +532,9 @@ func (conn *connection) getReceivedInfo(envelope Envelope) []byte {
        }
        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
index fd4fef3102c6b6273aae7b555b78831039b68c1b..72b559259aa8a805d98d977eb702eec02cd3e76c 100644 (file)
@@ -28,9 +28,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
 }
@@ -496,18 +496,6 @@ func TestBasicRelay(t *testing.T) {
        }
 }
 
-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()
@@ -515,14 +503,12 @@ func TestSendAsRelay(t *testing.T) {
        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("Subject: Send-as relay [sendas:source]\n"))
                        ok(t, conn.PrintfLine("We've switched the senders!"))
                        ok(t, conn.PrintfLine("."))
                        readCodeLine(t, conn, 250)
@@ -557,6 +543,10 @@ func TestSendAsRelay(t *testing.T) {
        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) {
@@ -566,14 +556,13 @@ func TestSendMultipleRelay(t *testing.T) {
        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("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)
@@ -605,7 +594,11 @@ func TestSendMultipleRelay(t *testing.T) {
                t.Errorf("Should not find %q in message %q", original, msg)
        }
 
-       if strings.Index(msg, "\nFrom: <source@example.com>\n") == -1 {
+       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)
+       }
 }
index a83f58237fd5b041e31d30786a503d561585c5ce..abbd4f7b86932517a4cb75af6f597194f1924ed9 100644 (file)
@@ -2,6 +2,7 @@ package smtp
 
 import (
        "crypto/tls"
+       "regexp"
        "fmt"
        "io"
        "net"
@@ -19,7 +20,7 @@ func (l ReplyLine) String() string {
        return fmt.Sprintf("%d %s", l.Code, l.Message)
 }
 
-const SendAsAddress = "sendas+"
+var SendAsSubject = regexp.MustCompile(`(?i)\[sendas:\s*([a-zA-Z0-9\.\-_]+)\]`)
 
 var (
        ReplyOK               = ReplyLine{250, "OK"}