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)
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 {
package smtp
import (
+ "bytes"
"crypto/rand"
"crypto/tls"
"encoding/base64"
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
state
line string
+ delivery
+ // For deliverOutbound, replaces the From and Reply-To values.
+ sendAs *mail.Address
+
ehlo string
mailFrom *mail.Address
rcptTo []mail.Address
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
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)
return
}
+ conn.handleSendAs(&data)
+
received := time.Now()
env := Envelope{
RemoteAddr: conn.remoteAddr,
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[:])
}
func (conn *connection) resetBuffers() {
+ conn.delivery = deliverUnknown
+ conn.sendAs = nil
conn.mailFrom = nil
conn.rcptTo = make([]mail.Address, 0)
}
blockList []string
tlsConfig *tls.Config
*userAuth
+ relayed []Envelope
}
func (s *testServer) Name() string {
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 {
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()
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},
{"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)
+ }
+}
--- /dev/null
+// 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
+}
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 {
// 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{}
func (*EmptyServerCallbacks) OnMessageDelivered(Envelope) *ReplyLine {
return nil
}
+
+func (*EmptyServerCallbacks) RelayMessage(Envelope) {
+}