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
 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
 
 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
 
 
 ## RFCs
 
index f1c195d220b0534df802b00097536d4ba823bb1d..eb868e4fd17ddf315b04af9b151e489bed423f5b 100644 (file)
@@ -330,11 +330,7 @@ func (conn *connection) doMAIL() {
        }
 
        if conn.server.VerifyAddress(*conn.mailFrom) == ReplyOK {
        }
 
        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
                }
                        conn.writeReply(550, "not authenticated")
                        return
                }
@@ -367,47 +363,12 @@ func (conn *connection) doRCPT() {
                return
        }
 
                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()",
        }
 
        conn.log.Info("doRCPT()",
@@ -438,8 +399,6 @@ func (conn *connection) doDATA() {
                return
        }
 
                return
        }
 
-       conn.handleSendAs(&data)
-
        received := time.Now()
        env := Envelope{
                RemoteAddr: conn.remoteAddr,
        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),
                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),
        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)
 
 
        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 {
 
        if conn.delivery == deliverInbound {
                if reply := conn.server.OnMessageDelivered(env); reply != nil {
@@ -475,46 +437,71 @@ func (conn *connection) doDATA() {
        conn.reply(ReplyOK)
 }
 
        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
        }
 
                return
        }
 
-       conn.mailFrom = conn.sendAs
-
        // Find the separator between the message header and body.
        // 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
        }
 
        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 {
        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
        }
                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 {
 }
 
 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("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
 
        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 {
 }
 
 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 {
        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
 }
        }
        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()
 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},
        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>"))
                {"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)
                        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, "\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) {
 }
 
 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},
        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>"))
                {"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)
                        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)
        }
 
                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)
        }
                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"
 
 import (
        "crypto/tls"
+       "regexp"
        "fmt"
        "io"
        "net"
        "fmt"
        "io"
        "net"
@@ -19,7 +20,7 @@ func (l ReplyLine) String() string {
        return fmt.Sprintf("%d %s", l.Code, l.Message)
 }
 
        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"}
 
 var (
        ReplyOK               = ReplyLine{250, "OK"}