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'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, edit the subject with [sendas:ADDRESS].
+
## 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)
- [POP3 Extension Mechanism, RFC 2449](https://tools.ietf.org/html/rfc2449)
Servers []Server
}
+const MailboxAccount = "mailbox@"
+
type Server struct {
// Domain is the second component of a mail address: <local-part@domain.com>.
Domain string
func (server *pop3Server) OpenMailbox(user, pass string) (pop3.Mailbox, error) {
for _, s := range server.config.Servers {
- if user == "mailbox@"+s.Domain && pass == s.MailboxPassword {
+ if user == MailboxAccount+s.Domain && pass == s.MailboxPassword {
return server.openMailbox(s.MaildropPath)
}
}
"net/mail"
"os"
"path"
- "strings"
"go.uber.org/zap"
return smtp.ReplyOK
}
-func (server *smtpServer) OnEHLO() *smtp.ReplyLine {
- return nil
+func (server *smtpServer) Authenticate(authz, authc, passwd string) bool {
+ authcAddr, err := mail.ParseAddress(authc)
+ if err != nil {
+ return false
+ }
+
+ authzAddr, err := mail.ParseAddress(authz)
+ if authz != "" && err != nil {
+ return false
+ }
+
+ domain := smtp.DomainForAddress(*authcAddr)
+ for _, s := range server.config.Servers {
+ if domain == s.Domain {
+ authOk := authc == MailboxAccount+s.Domain && passwd == s.MailboxPassword
+ if authzAddr != nil {
+ authOk = authOk && smtp.DomainForAddress(*authzAddr) == domain
+ }
+ return authOk
+ }
+ }
+ return false
}
func (server *smtpServer) OnMessageDelivered(en smtp.Envelope) *smtp.ReplyLine {
return nil
}
-func (server *smtpServer) maildropForAddress(addr mail.Address) string {
- domainIdx := strings.LastIndex(addr.Address, "@")
- if domainIdx == -1 {
- return ""
- }
-
- domain := addr.Address[domainIdx+1:]
+func (server *smtpServer) RelayMessage(en smtp.Envelope) {
+ log := server.log.With(zap.String("id", en.ID))
+ go smtp.RelayMessage(server, en, log)
+}
+func (server *smtpServer) maildropForAddress(addr mail.Address) string {
+ domain := smtp.DomainForAddress(addr)
for _, s := range server.config.Servers {
if domain == s.Domain {
return s.MaildropPath
package smtp
import (
+ "bytes"
"crypto/rand"
"crypto/tls"
+ "encoding/base64"
"fmt"
"net"
"net/mail"
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
log *zap.Logger
+ // The authcid from a PLAIN SASL login. Non-empty iff tls is non-nil and
+ // doAUTH() succeeded.
+ authc string
+
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
}
- conn.log.Info("ReadLine()", zap.String("line", conn.line))
+ lineForLog := conn.line
+ const authPlain = "AUTH PLAIN "
+ if strings.HasPrefix(conn.line, authPlain) {
+ lineForLog = authPlain + "[redacted]"
+ }
+ conn.log.Info("ReadLine()", zap.String("line", lineForLog))
var cmd string
if _, err = fmt.Sscanf(conn.line, "%s", &cmd); err != nil {
conn.doEHLO()
case "STARTTLS":
conn.doSTARTTLS()
+ case "AUTH":
+ conn.doAUTH()
case "MAIL":
conn.doMAIL()
case "RCPT":
if conn.server.TLSConfig() != nil && conn.tls == nil {
conn.tp.PrintfLine("250-STARTTLS")
}
+ if conn.tls != nil {
+ conn.tp.PrintfLine("250-AUTH PLAIN")
+ }
conn.tp.PrintfLine("250 SIZE %d", 40960000)
}
conn.log.Info("TLS connection done", zap.String("state", conn.getTransportString()))
}
+func (conn *connection) doAUTH() {
+ if conn.state != stateInitial || conn.tls == nil {
+ conn.reply(ReplyBadSequence)
+ return
+ }
+
+ if conn.authc != "" {
+ conn.writeReply(503, "already authenticated")
+ return
+ }
+
+ var cmd, authType, authString string
+ n, err := fmt.Sscanf(conn.line, "%s %s %s", &cmd, &authType, &authString)
+ if n < 2 {
+ conn.reply(ReplyBadSyntax)
+ return
+ }
+
+ if authType != "PLAIN" {
+ conn.writeReply(504, "unrecognized auth type")
+ return
+ }
+
+ // If only 2 tokens were scanned, then an initial response was not provided.
+ if n == 2 && conn.line[len(conn.line)-1] != ' ' {
+ conn.reply(ReplyBadSyntax)
+ return
+ }
+
+ conn.log.Info("doAUTH()")
+
+ if authString == "" {
+ conn.writeReply(334, " ")
+
+ authString, err = conn.tp.ReadLine()
+ if err != nil {
+ conn.log.Error("failed to read auth line", zap.Error(err))
+ conn.reply(ReplyBadSyntax)
+ return
+ }
+ }
+
+ authBytes, err := base64.StdEncoding.DecodeString(authString)
+ if err != nil {
+ conn.reply(ReplyBadSyntax)
+ return
+ }
+
+ authParts := strings.Split(string(authBytes), "\x00")
+ if len(authParts) != 3 {
+ conn.log.Error("bad auth line syntax")
+ conn.reply(ReplyBadSyntax)
+ return
+ }
+
+ if !conn.server.Authenticate(authParts[0], authParts[1], authParts[2]) {
+ conn.log.Error("failed to authenticate", zap.String("authc", authParts[1]))
+ conn.writeReply(535, "invalid credentials")
+ return
+ }
+
+ conn.log.Info("authenticated", zap.String("authz", authParts[0]), zap.String("authc", authParts[1]))
+ conn.authc = authParts[1]
+ conn.reply(ReplyOK)
+}
+
func (conn *connection) doMAIL() {
if conn.state != stateInitial {
conn.reply(ReplyBadSequence)
return
}
+ if conn.server.VerifyAddress(*conn.mailFrom) == ReplyOK {
+ if DomainForAddress(*conn.mailFrom) != DomainForAddressString(conn.authc) {
+ 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 {
+ 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))
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)
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),
- 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...)
+ env.Data = append(trace, env.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(env *Envelope) {
+ if conn.delivery != deliverOutbound {
+ return
+ }
+
+ // Find the separator between the message header and body.
+ headerIdx := bytes.Index(env.Data, []byte("\n\n"))
+ if headerIdx == -1 {
+ conn.log.Error("send-as: could not find headers index")
+ 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
+ }
+ }
+
+ 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 From header")
+ return
+ }
+
+ sendAs := SendAsSubject.FindSubmatchIndex(headers[subjectIdx])
+ if sendAs == nil {
+ // No send-as modification.
+ return
+ }
+
+ // 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 {
var idBytes [4]byte
rand.Read(idBytes[:])
}
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
}
func (conn *connection) resetBuffers() {
+ conn.delivery = deliverUnknown
+ conn.sendAs = nil
conn.mailFrom = nil
conn.rcptTo = make([]mail.Address, 0)
}
package smtp
import (
+ "crypto/tls"
+ "encoding/base64"
"fmt"
"net"
"net/mail"
}
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
}
return l
}
+type userAuth struct {
+ authz, authc, passwd string
+}
+
type testServer struct {
EmptyServerCallbacks
+ domain string
blockList []string
+ tlsConfig *tls.Config
+ *userAuth
+ relayed []Envelope
}
func (s *testServer) Name() string {
return "Test-Server"
}
+func (s *testServer) TLSConfig() *tls.Config {
+ return s.tlsConfig
+}
+
func (s *testServer) VerifyAddress(addr mail.Address) ReplyLine {
+ if DomainForAddress(addr) != s.domain {
+ return ReplyBadMailbox
+ }
for _, block := range s.blockList {
if strings.ToLower(block) == addr.Address {
return ReplyBadMailbox
return ReplyOK
}
+func (s *testServer) Authenticate(authz, authc, passwd string) bool {
+ return s.userAuth.authz == authz &&
+ s.userAuth.authc == authc &&
+ 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 {
func runTableTest(t testing.TB, conn *textproto.Conn, seq []requestResponse) {
for i, rr := range seq {
- t.Logf("%s case %d", _fl(1), i)
ok(t, conn.PrintfLine(rr.request))
if rr.handler != nil {
rr.handler(t, conn)
} else {
readCodeLine(t, conn, rr.responseCode)
}
+ if t.Failed() {
+ t.Logf("%s case %d", _fl(1), i)
+ }
}
}
// RFC 5321 § D.1
func TestScenarioTypical(t *testing.T) {
s := testServer{
+ domain: "foo.com",
blockList: []string{"Green@foo.com"},
}
l := runServer(t, &s)
func TestVerifyAddress(t *testing.T) {
s := testServer{
+ domain: "test.mail",
blockList: []string{"banned@test.mail"},
}
l := runServer(t, &s)
}
func TestCaseSensitivty(t *testing.T) {
- s := &testServer{}
- s.blockList = []string{"reject@mail.com"}
+ s := &testServer{
+ domain: "mail.com",
+ blockList: []string{"reject@mail.com"},
+ }
l := runServer(t, s)
defer l.Close()
}
}
+
+func getTLSConfig(t *testing.T) *tls.Config {
+ cert, err := tls.LoadX509KeyPair("../testtls/domain.crt", "../testtls/domain.key")
+ if err != nil {
+ t.Fatal(err)
+ return nil
+ }
+ return &tls.Config{
+ ServerName: "localhost",
+ Certificates: []tls.Certificate{cert},
+ InsecureSkipVerify: true,
+ }
+}
+
+func setupTLSClient(t *testing.T, addr net.Addr) *textproto.Conn {
+ nc, err := net.Dial(addr.Network(), addr.String())
+ ok(t, err)
+
+ conn := textproto.NewConn(nc)
+ readCodeLine(t, conn, 220)
+
+ ok(t, conn.PrintfLine("EHLO test-tls"))
+ _, resp, err := conn.ReadResponse(250)
+ ok(t, err)
+ if !strings.Contains(resp, "STARTTLS\n") {
+ t.Errorf("STARTTLS not advertised")
+ }
+
+ ok(t, conn.PrintfLine("STARTTLS"))
+ readCodeLine(t, conn, 220)
+
+ tc := tls.Client(nc, getTLSConfig(t))
+ err = tc.Handshake()
+ ok(t, err)
+
+ conn = textproto.NewConn(tc)
+
+ ok(t, conn.PrintfLine("EHLO test-tls-started"))
+ _, resp, err = conn.ReadResponse(250)
+ ok(t, err)
+ if strings.Contains(resp, "STARTTLS\n") {
+ t.Errorf("STARTTLS advertised when already started")
+ }
+
+ 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()
+
+ setupTLSClient(t, l.Addr())
+}
+
+func TestAuthWithoutTLS(t *testing.T) {
+ l := runServer(t, &testServer{})
+ defer l.Close()
+
+ conn := createClient(t, l.Addr())
+ readCodeLine(t, conn, 220)
+
+ ok(t, conn.PrintfLine("EHLO test"))
+ _, resp, err := conn.ReadResponse(250)
+ ok(t, err)
+
+ if strings.Contains(resp, "AUTH") {
+ t.Errorf("AUTH should not be advertised over plaintext")
+ }
+}
+
+func TestAuth(t *testing.T) {
+ l := runServer(t, &testServer{
+ tlsConfig: getTLSConfig(t),
+ userAuth: &userAuth{
+ authz: "-authz-",
+ authc: "-authc-",
+ passwd: "goats",
+ },
+ })
+ defer l.Close()
+
+ conn := setupTLSClient(t, l.Addr())
+
+ runTableTest(t, conn, []requestResponse{
+ {"AUTH", 501, nil},
+ {"AUTH OAUTHBEARER", 504, nil},
+ {"AUTH PLAIN", 501, nil}, // Bad syntax, missing space.
+ {"AUTH PLAIN ", 334, nil},
+ {b64enc("abc\x00def\x00ghf"), 535, nil},
+ {"AUTH PLAIN ", 334, nil},
+ {b64enc("\x00"), 501, nil},
+ {"AUTH PLAIN ", 334, nil},
+ {"this isn't base 64", 501, nil},
+ {"AUTH PLAIN ", 334, nil},
+ {b64enc("-authz-\x00-authc-\x00goats"), 250, nil},
+ {"AUTH PLAIN ", 503, nil}, // Already authenticated.
+ {"NOOP", 250, nil},
+ })
+}
+
+func TestAuthNoInitialResponse(t *testing.T) {
+ l := runServer(t, &testServer{
+ tlsConfig: getTLSConfig(t),
+ userAuth: &userAuth{
+ authz: "",
+ authc: "user",
+ passwd: "longpassword",
+ },
+ })
+ defer l.Close()
+
+ conn := setupTLSClient(t, l.Addr())
+
+ runTableTest(t, conn, []requestResponse{
+ {"AUTH PLAIN " + b64enc("\x00user\x00longpassword"), 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 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},
+ {"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 [sendas:source]\n"))
+ ok(t, conn.PrintfLine("We've switched the senders!"))
+ ok(t, conn.PrintfLine("."))
+ readCodeLine(t, conn, 250)
+ }},
+ })
+
+ if len(server.relayed) != 1 {
+ t.Fatalf("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)
+ }
+
+ 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) {
+ 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:<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: 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)
+ }},
+ })
+
+ if len(server.relayed) != 1 {
+ t.Fatalf("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: 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)
+ }
+}
--- /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 (
+ "crypto/tls"
+ "net"
+ "net/smtp"
+
+ "github.com/uber-go/zap"
+)
+
+func RelayMessage(server Server, env Envelope, log zap.Logger) {
+ for _, rcptTo := range env.RcptTo {
+ sendLog := log.With(zap.String("address", rcptTo.Address))
+
+ domain := DomainForAddress(rcptTo)
+ mx, err := net.LookupMX(domain)
+ if err != nil || len(mx) < 1 {
+ sendLog.Error("failed to lookup MX records",
+ zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+ host := mx[0].Host + ":25"
+ relayMessageToHost(server, env, sendLog, rcptTo.Address, host)
+ }
+}
+
+func relayMessageToHost(server Server, env Envelope, log zap.Logger, to, host string) {
+ from := env.MailFrom.Address
+
+ c, err := smtp.Dial(host)
+ if err != nil {
+ // TODO - retry, or look at other MX records
+ log.Error("failed to dial host",
+ zap.String("host", host),
+ zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+ defer c.Quit()
+
+ log = log.With(zap.String("host", host))
+
+ if err = c.Hello(server.Name()); err != nil {
+ log.Error("failed to HELO", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+
+ if hasTls, _ := c.Extension("STARTTLS"); hasTls {
+ config := &tls.Config{ServerName: host}
+ if err = c.StartTLS(config); err != nil {
+ log.Error("failed to STARTTLS", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+ }
+
+ if err = c.Mail(from); err != nil {
+ log.Error("failed MAIL FROM", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+
+ if err = c.Rcpt(to); err != nil {
+ log.Error("failed to RCPT TO", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+
+ wc, err := c.Data()
+ if err != nil {
+ log.Error("failed to DATA", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+
+ _, err = wc.Write(env.Data)
+ if err != nil {
+ wc.Close()
+ log.Error("failed to write DATA", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+
+ if err = wc.Close(); err != nil {
+ log.Error("failed to close DATA", zap.Error(err))
+ deliverRelayFailure(env, err)
+ return
+ }
+}
+
+func deliverRelayFailure(env Envelope, err error) {
+ // TODO: constructo a delivery status notification
+}
--- /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 (
+ "bytes"
+ "net/mail"
+ "testing"
+
+ "github.com/uber-go/zap"
+)
+
+type deliveryServer struct {
+ testServer
+ messages []Envelope
+}
+
+func (s *deliveryServer) OnMessageDelivered(env Envelope) *ReplyLine {
+ s.messages = append(s.messages, env)
+ return nil
+}
+
+func TestRelayRoundTrip(t *testing.T) {
+ s := &deliveryServer{
+ testServer: testServer{domain: "receive.net"},
+ }
+ l := runServer(t, s)
+ defer l.Close()
+
+ env := Envelope{
+ MailFrom: mail.Address{Address: "from@sender.org"},
+ RcptTo: []mail.Address{{Address: "to@receive.net"}},
+ Data: []byte("~~~Message~~~\n"),
+ ID: "ididid",
+ }
+
+ relayMessageToHost(s, env, zap.New(zap.NullEncoder()), env.RcptTo[0].Address, l.Addr().String())
+
+ if len(s.messages) != 1 {
+ t.Errorf("Expected 1 message to be delivered, got %d", len(s.messages))
+ return
+ }
+
+ received := s.messages[0]
+
+ if env.MailFrom.Address != received.MailFrom.Address {
+ t.Errorf("Expected MailFrom %s, got %s", env.MailFrom.Address, received.MailFrom.Address)
+ }
+ if len(received.RcptTo) != 1 {
+ t.Errorf("Expected 1 RcptTo, got %d", len(received.RcptTo))
+ return
+ }
+ if env.RcptTo[0].Address != received.RcptTo[0].Address {
+ t.Errorf("Expected RcptTo %s, got %s", env.RcptTo[0].Address, received.RcptTo[0].Address)
+ }
+
+ if !bytes.HasSuffix(received.Data, env.Data) {
+ t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", string(env.Data), string(received.Data))
+ }
+}
import (
"crypto/tls"
+ "regexp"
"fmt"
"io"
"net"
"net/mail"
+ "strings"
"time"
)
return fmt.Sprintf("%d %s", l.Code, l.Message)
}
+var SendAsSubject = regexp.MustCompile(`(?i)\[sendas:\s*([a-zA-Z0-9\.\-_]+)\]`)
+
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 {
+ return DomainForAddressString(addr.Address)
+}
+
+func DomainForAddressString(address string) string {
+ domainIdx := strings.LastIndex(address, "@")
+ if domainIdx == -1 {
+ return ""
+ }
+ return address[domainIdx+1:]
+}
+
type Envelope struct {
RemoteAddr net.Addr
EHLO string
type Server interface {
Name() string
TLSConfig() *tls.Config
- OnEHLO() *ReplyLine
VerifyAddress(mail.Address) ReplyLine
+ // 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{}
return nil
}
-func (*EmptyServerCallbacks) OnEHLO() *ReplyLine {
- return nil
-}
-
func (*EmptyServerCallbacks) VerifyAddress(mail.Address) ReplyLine {
return ReplyOK
}
+func (*EmptyServerCallbacks) Authenticate(authz, authc, passwd string) bool {
+ return false
+}
+
func (*EmptyServerCallbacks) OnMessageDelivered(Envelope) *ReplyLine {
return nil
}
+
+func (*EmptyServerCallbacks) RelayMessage(Envelope) {
+}
--- /dev/null
+package smtp
+
+import (
+ "net/mail"
+ "testing"
+)
+
+func TestDomainForAddress(t *testing.T) {
+ cases := []struct{
+ address, domain string
+ }{
+ {"foo@bar.com", "bar.com"},
+ {"abc", ""},
+ {"abc@one.two.three.four.net", "one.two.three.four.net"},
+ }
+ for i, c := range cases {
+ actual := DomainForAddress(mail.Address{Address: c.address})
+ if actual != c.domain {
+ t.Errorf("case %d, got %q, expected %q", i, actual, c.domain)
+ }
+ }
+}
--- /dev/null
+package main
+
+import (
+ "testing"
+)
+
+var testConfig = Config{
+ Servers: []Server{
+ Server{
+ Domain: "domain1.net",
+ MailboxPassword: "d1",
+ },
+ Server{
+ Domain: "domain2.xyz",
+ MailboxPassword: "d2",
+ },
+ },
+}
+
+func TestAuthenticate(t *testing.T) {
+ server := smtpServer{config: testConfig}
+
+ authTests := []struct {
+ authz, authc, passwd string
+ ok bool
+ }{
+ {"foo@domain1.net", "mailbox@domain1.net", "d1", true},
+ {"", "mailbox@domain1.net", "d1", true},
+ {"foo@domain2.xyz", "mailbox@domain1.xyz", "d1", false},
+ {"foo@domain2.xyz", "mailbox@domain1.xyz", "d2", false},
+ {"foo@domain2.xyz", "mailbox@domain2.xyz", "d2", true},
+ {"invalid", "mailbox@domain2.xyz", "d2", false},
+ {"", "mailbox@domain2.xyz", "d2", true},
+ {"", "", "", false},
+ }
+
+ for i, test := range authTests {
+ actual := server.Authenticate(test.authz, test.authc, test.passwd)
+ if actual != test.ok {
+ t.Errorf("Test %d, got %v, expected %v", i, actual, test.ok)
+ }
+ }
+}
--- /dev/null
+-----BEGIN CERTIFICATE-----
+MIID0DCCArigAwIBAgIJAKKsGKE6sUkAMA0GCSqGSIb3DQEBBQUAME4xCzAJBgNV
+BAYTAlVTMREwDwYDVQQIEwhOZXcgWW9yazEYMBYGA1UEChMPVEVTVCBNQUlMUE9Q
+Qk9YMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMTgwOTA2MDAzOTQxWhcNMTgxMDA2
+MDAzOTQxWjBOMQswCQYDVQQGEwJVUzERMA8GA1UECBMITmV3IFlvcmsxGDAWBgNV
+BAoTD1RFU1QgTUFJTFBPUEJPWDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkq
+hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRckvnfo5MdbIWZHLP+g1wDrBLC3LS3M
+9JFumGXXe19Ooy9a+WvBK3tnFfEhpMIKJ3iphgPlf3fU8W96kuE3o6rPRUzpv6FU
+z+w1FTBiOd1FfUmKwULdjmMFFdl2nYzjp4le9yfaUDNdu8bj9Wk2tL6sBaO6gkHr
+thJiPPtCv19YxGPG5FZIDnlDwLkwuaUxQCTgZ7BZ0qM036gmXJ6gYd1Scgz5iSHC
+Pfj9v1dEOPowA9MRrDa9sQdjrFCq2vgdsdsVdVfoWhHeQfd6PCFUTy06xDHfGnor
+/+wAklbnIVCnVbyQeQHpPkQjlzcVMZkHnxwfG4CJ/WgF+inzQ0hNTQIDAQABo4Gw
+MIGtMB0GA1UdDgQWBBSqQHNqsbIav6Q0SYwla379Q19YsjB+BgNVHSMEdzB1gBSq
+QHNqsbIav6Q0SYwla379Q19YsqFSpFAwTjELMAkGA1UEBhMCVVMxETAPBgNVBAgT
+CE5ldyBZb3JrMRgwFgYDVQQKEw9URVNUIE1BSUxQT1BCT1gxEjAQBgNVBAMTCWxv
+Y2FsaG9zdIIJAKKsGKE6sUkAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD
+ggEBAJeT2v5N+i15zlEeOVSsuvV/aGe068K5e+FE1qOnkmBsvqN2/BmWT5oeJuNc
+AT0ZyKI1vR5/Enq1Fta5xSNWLVeFQwm4DYEFxJ21SGWnY6+hl213nr7x0pmLG3tT
+YFq9/A+4iG5LQuDroQOC2TAqZFMTpdgLQgFZYKJvI4rk6/QrcI2pdMdwpAIJgMgd
+USV/MHoj30ll17cAukHnaZGKxkkZu0BKhadOEkB7p4zSZsVFWJfGsi5Pf3+LFmyE
+dkxewA5SVD7KwVkZ+wSkxEBuaIYAArbuxc9aT2u4H3+fxVrd2UnuDeq0nv0lMZN/
+eAHjIwvvWme+6AxRhIA5z5eTpcA=
+-----END CERTIFICATE-----
--- /dev/null
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAqRckvnfo5MdbIWZHLP+g1wDrBLC3LS3M9JFumGXXe19Ooy9a
++WvBK3tnFfEhpMIKJ3iphgPlf3fU8W96kuE3o6rPRUzpv6FUz+w1FTBiOd1FfUmK
+wULdjmMFFdl2nYzjp4le9yfaUDNdu8bj9Wk2tL6sBaO6gkHrthJiPPtCv19YxGPG
+5FZIDnlDwLkwuaUxQCTgZ7BZ0qM036gmXJ6gYd1Scgz5iSHCPfj9v1dEOPowA9MR
+rDa9sQdjrFCq2vgdsdsVdVfoWhHeQfd6PCFUTy06xDHfGnor/+wAklbnIVCnVbyQ
+eQHpPkQjlzcVMZkHnxwfG4CJ/WgF+inzQ0hNTQIDAQABAoIBAGmTocWraScvsp7w
+FZDrK6oTUKrlC/qRll8+TyeorxrBL4CEmPETbtGPg5YXsUIGRgDPPkoHNMyaLcNy
+L752ER+ID1Ld6zVTrnkEq0BHrY0js7e+q3xwG5ZEDXDPD1jgF2UMSNdZct6Qs/4C
++WLKBvZj91SuHk4mit5sLBqXZ93EzvmK6mb2OSwrtla2500Pf1CsY9VAT75z0kBi
+cLV+WYrA1Q84eNqFm4gW1wjfz9xs8+u+jRTgtI0X4HkXpDBzliIecxR8RmjPG0ET
+xykW4gfnVZoTPI1iRnCdaXhzP2oyx96/i1BUWoNQmQjki0TzOY56XgiLRpWnrD5b
+XaoHrqECgYEA0s6STz8qe5Cz96J3B4nm4yZcvsfTPm9R/L5k9dg9bPed8KcqPuT8
+KSA/1JWk8Z4XcHdO3DilvHVYgENEBBNEPKfjL5Dz9r62EEn4Xjt2pHjgSkDAEKtr
+BRKXhdTWCoT0fpx8I6yCPzCDV6IA4yfT2J7RZeuoK5hYXVaE5I1oGUcCgYEAzVcZ
+9qhR/DkStQ41wtyepZXHmkgzwqi2drb6tj+EsQfi/jHm1SX7uCkrbYgLwWMARmBO
+zS2cqEKP7Fzv1yBw0ooDxvGOqtsi7HKFsHeYWXXZCrrxiinMg1lpVjfLFchW10jY
+deiUBDGeYsu8gCe1ekOVRJrXbFpC8mxxe2ICrssCgYEAjHC8fnkZh1qe2vJslCQm
+Itxy21LrA+RL3bLGNhbKzWal3Saw+Ve6OnfWrnzHd4SYHwANFJ/UopoWzNSDYqen
+RTWgIBdUwOTLDE0LX1QENYyl+DHtAu4AjU+WjL1/n2B2NkdwWJ/b4dcjGWW/a5Yk
+B2O/I0R9NBX5gK1cOZuPZ48CgYAoVJs83wJ7T5plBU154GsoiqqRmuzPpuNvnbDQ
+atldC/eBhbuY0cUG/s8QzE/Cw/ch23iew/6o7anm+roAvtZqA8GKKZej5zaMylGH
+v3Wk3IismtsmD9+jTMRrsrmopZio4B3jyrKHwFcjgHCdmy8BvJRszRzSo0fS5YnE
+ehOc0QKBgQCGhYKMbhGNobsXnjxKQDRlrKxvWXSHtx6wgcyAPdb1iqaFe+jIEgvx
+6Vd63Jin2/kruSr8m9VSltkdXmcbLKRNff55J8dyH322Z09R8WrVkCtw8nWIWvgJ
+EVR2rG4y0S2YhzhO8bOlybZS0egg93RSye0Xd3pPyLFgAXmixgqmgw==
+-----END RSA PRIVATE KEY-----