+ // 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"
"crypto/rand"
"crypto/tls"
+ "encoding/base64"
"fmt"
"net"
"net/mail"
"strings"
"time"
- "github.com/uber-go/zap"
+ "go.uber.org/zap"
)
type state int
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
esmtp bool
tls *tls.ConnectionState
- log zap.Logger
-
+ 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
}
- func AcceptConnection(netConn net.Conn, server Server, log zap.Logger) {
+ func AcceptConnection(netConn net.Conn, server Server, log *zap.Logger) {
conn := connection{
server: server,
tp: textproto.NewConn(netConn),
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)
}
+ // 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"
+ "encoding/base64"
"fmt"
"net"
"net/mail"
"testing"
"time"
- "github.com/uber-go/zap"
+ "go.uber.org/zap"
)
func _fl(depth 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 {
- 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
}
if err != nil {
return
}
- go AcceptConnection(conn, server, zap.New(zap.NullEncoder()))
+ go AcceptConnection(conn, server, zap.NewNop())
}
}()
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)
+ }
+}