package smtp
import (
- "crypto/rand"
+ "bytes"
"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)
MailFrom: *conn.mailFrom,
RcptTo: conn.rcptTo,
Received: received,
- ID: conn.envelopeID(received),
+ ID: generateEnvelopeId("m", 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) envelopeID(t time.Time) string {
- var idBytes [4]byte
- rand.Read(idBytes[:])
- return fmt.Sprintf("m.%d.%x", t.UnixNano(), idBytes)
- }
+ func (conn *connection) handleSendAs(env *Envelope) {
+ if conn.delivery != deliverOutbound {
+ return
+ }
- func (conn *connection) getReceivedInfo(envelope Envelope) []byte {
- rhost, _, err := net.SplitHostPort(conn.remoteAddr.String())
- if err != nil {
- rhost = conn.remoteAddr.String()
+ // 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
}
- rhosts, err := net.LookupAddr(rhost)
- if err == nil {
- rhost = fmt.Sprintf("%s [%s]", rhosts[0], rhost)
+ 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
}
- base := fmt.Sprintf("Received: from %s (%s)\r\n ", conn.ehlo, rhost)
+ 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) getReceivedInfo(envelope Envelope) []byte {
+ base := fmt.Sprintf("Received: from %s (%s)\r\n ", conn.ehlo, lookupRemoteHost(conn.remoteAddr))
with := "SMTP"
if conn.esmtp {
}
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
}
ciphers := map[uint16]string{
- tls.TLS_RSA_WITH_RC4_128_SHA: "TLS_RSA_WITH_RC4_128_SHA",
- tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
- tls.TLS_RSA_WITH_AES_128_CBC_SHA: "TLS_RSA_WITH_AES_128_CBC_SHA",
- tls.TLS_RSA_WITH_AES_256_CBC_SHA: "TLS_RSA_WITH_AES_256_CBC_SHA",
- tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "TLS_RSA_WITH_AES_128_GCM_SHA256",
- tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "TLS_RSA_WITH_AES_256_GCM_SHA384",
- tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
- tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
- tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
- tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: "TLS_ECDHE_RSA_WITH_RC4_128_SHA",
- tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
- tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
- tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
- tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
- tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
- tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
- tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
+ tls.TLS_RSA_WITH_RC4_128_SHA: "TLS_RSA_WITH_RC4_128_SHA",
+ tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
+ tls.TLS_RSA_WITH_AES_128_CBC_SHA: "TLS_RSA_WITH_AES_128_CBC_SHA",
+ tls.TLS_RSA_WITH_AES_256_CBC_SHA: "TLS_RSA_WITH_AES_256_CBC_SHA",
+ tls.TLS_RSA_WITH_AES_128_CBC_SHA256: "TLS_RSA_WITH_AES_128_CBC_SHA256",
+ tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "TLS_RSA_WITH_AES_128_GCM_SHA256",
+ tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "TLS_RSA_WITH_AES_256_GCM_SHA384",
+ tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
+ tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: "TLS_ECDHE_RSA_WITH_RC4_128_SHA",
+ tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
+ tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
+ tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
+ tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
+ tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
+ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
+ tls.TLS_AES_128_GCM_SHA256: "TLS_AES_128_GCM_SHA256",
+ tls.TLS_AES_256_GCM_SHA384: "TLS_AES_256_GCM_SHA384",
+ tls.TLS_CHACHA20_POLY1305_SHA256: "TLS_CHACHA20_POLY1305_SHA256",
}
versions := map[uint16]string{
tls.VersionSSL30: "SSLv3.0",
tls.VersionTLS10: "TLSv1.0",
tls.VersionTLS11: "TLSv1.1",
tls.VersionTLS12: "TLSv1.2",
+ tls.VersionTLS13: "TLSv1.3",
}
state := conn.tls
}
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 main
import (
+ "bytes"
+ "io/ioutil"
+ "net/mail"
+ "os"
+ "path/filepath"
"testing"
+
+ "go.uber.org/zap"
+
+ "src.bluestatic.org/mailpopbox/smtp"
)
-var testConfig = Config{
- Servers: []Server{
- Server{
- Domain: "domain1.net",
- MailboxPassword: "d1",
+func TestVerifyAddress(t *testing.T) {
+ dir, err := ioutil.TempDir("", "maildrop")
+ if err != nil {
+ t.Errorf("Failed to create temp dir: %v", err)
+ return
+ }
+ defer os.RemoveAll(dir)
+
+ s := smtpServer{
+ config: Config{
+ Hostname: "mx.example.com",
+ Servers: []Server{
+ {
+ Domain: "example.com",
+ MaildropPath: dir,
+ },
+ },
},
- Server{
- Domain: "domain2.xyz",
- MailboxPassword: "d2",
+ log: zap.NewNop(),
+ }
+
+ if s.VerifyAddress(mail.Address{Address: "example@example.com"}) != smtp.ReplyOK {
+ t.Errorf("Valid mailbox is not reported to be valid")
+ }
+ if s.VerifyAddress(mail.Address{Address: "mailbox@example.com"}) != smtp.ReplyOK {
+ t.Errorf("Valid mailbox is not reported to be valid")
+ }
+ if s.VerifyAddress(mail.Address{Address: "hello@other.net"}) == smtp.ReplyOK {
+ t.Errorf("Invalid mailbox reports to be valid")
+ }
+ if s.VerifyAddress(mail.Address{Address: "hello@mx.example.com"}) == smtp.ReplyOK {
+ t.Errorf("Invalid mailbox reports to be valid")
+ }
+ if s.VerifyAddress(mail.Address{Address: "unknown"}) == smtp.ReplyOK {
+ t.Errorf("Invalid mailbox reports to be valid")
+ }
+}
+
+func TestMessageDelivery(t *testing.T) {
+ dir, err := ioutil.TempDir("", "maildrop")
+ if err != nil {
+ t.Errorf("Failed to create temp dir: %v", err)
+ return
+ }
+ defer os.RemoveAll(dir)
+
+ s := smtpServer{
+ config: Config{
+ Hostname: "mx.example.com",
+ Servers: []Server{
+ {
+ Domain: "example.com",
+ MaildropPath: dir,
+ },
+ },
},
- },
+ log: zap.NewNop(),
+ }
+
+ env := smtp.Envelope{
+ MailFrom: mail.Address{Address: "sender@mail.net"},
+ RcptTo: []mail.Address{{Address: "receive@example.com"}},
+ Data: []byte("Hello, world"),
+ ID: "msgid",
+ }
+
+ if rl := s.OnMessageDelivered(env); rl != nil {
+ t.Errorf("Failed to deliver message: %v", rl)
+ }
+
+ f, err := os.Open(filepath.Join(dir, "msgid.msg"))
+ if err != nil {
+ t.Errorf("Failed to open delivered message: %v", err)
+ }
+ defer f.Close()
+
+ data, err := ioutil.ReadAll(f)
+ if err != nil {
+ t.Errorf("Failed to read message: %v", err)
+ }
+
+ if !bytes.Contains(data, env.Data) {
+ t.Errorf("Could not find expected data in message")
+ }
}
- server := smtpServer{config: testConfig}
+
+ func TestAuthenticate(t *testing.T) {
++ server := smtpServer{
++ config: Config{
++ Servers: []Server{
++ Server{
++ Domain: "domain1.net",
++ MailboxPassword: "d1",
++ },
++ Server{
++ Domain: "domain2.xyz",
++ MailboxPassword: "d2",
++ },
++ },
++ },
++ }
+
+ 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)
+ }
+ }
+ }