From de304bb075e779fdeaa5a61bcd098c22ad5744ba Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Wed, 29 Apr 2020 22:17:03 -0400 Subject: [PATCH] Add support for outbound SMTP delivery. 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 on the message. --- README.md | 15 ++++ smtp.go | 5 ++ smtp/conn.go | 152 +++++++++++++++++++++++++++++++++--- smtp/conn_test.go | 193 +++++++++++++++++++++++++++++++++++++++++++++- smtp/relay.go | 49 ++++++++++++ smtp/server.go | 26 +++++-- 6 files changed, 418 insertions(+), 22 deletions(-) create mode 100644 smtp/relay.go diff --git a/README.md b/README.md index 03a343f..da6ae6c 100644 --- 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 572e1f6..2bbb154 100644 --- 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 { diff --git a/smtp/conn.go b/smtp/conn.go index fd133d7..11995b1 100644 --- a/smtp/conn.go +++ b/smtp/conn.go @@ -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) } diff --git a/smtp/conn_test.go b/smtp/conn_test.go index 7083907..27dd63f 100644 --- a/smtp/conn_test.go +++ b/smtp/conn_test.go @@ -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:", 550, nil}, + {"MAIL FROM:", 550, nil}, + {"AUTH PLAIN", 334, nil}, + {b64enc("\x00mailbox@example.com\x00test"), 250, nil}, + {"MAIL FROM:", 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:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"DATA", 354, func(t testing.TB, conn *textproto.Conn) { + readCodeLine(t, conn, 354) + + ok(t, conn.PrintfLine("From: ")) + ok(t, conn.PrintfLine("To: ")) + 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:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"RCPT TO:", 550, nil}, + {"RCPT TO:", 550, nil}, + }) +} + +func TestSendAsRelay(t *testing.T) { + server, l, conn := setupRelayTest(t) + defer l.Close() + + runTableTest(t, conn, []requestResponse{ + {"MAIL FROM:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"RCPT TO:", 550, nil}, + {"DATA", 354, func(t testing.TB, conn *textproto.Conn) { + readCodeLine(t, conn, 354) + + ok(t, conn.PrintfLine("From: ")) + ok(t, conn.PrintfLine("To: ")) + 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: \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:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"RCPT TO:", 250, nil}, + {"DATA", 354, func(t testing.TB, conn *textproto.Conn) { + readCodeLine(t, conn, 354) + + ok(t, conn.PrintfLine("To: Cindy , Sam ")) + ok(t, conn.PrintfLine("From: ")) + 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: \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 index 0000000..23333e5 --- /dev/null +++ b/smtp/relay.go @@ -0,0 +1,49 @@ +// mailpopbox +// Copyright 2020 Blue Static +// 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 +} diff --git a/smtp/server.go b/smtp/server.go index f9ecb1d..a83f582 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -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) { +} -- 2.22.5