Produce delivery-status failure notifications when failing to relay a message.
authorRobert Sesek <rsesek@bluestatic.org>
Sat, 6 Jun 2020 16:06:32 +0000 (12:06 -0400)
committerRobert Sesek <rsesek@bluestatic.org>
Sat, 6 Jun 2020 16:06:32 +0000 (12:06 -0400)
README.md
smtp/conn.go
smtp/relay.go
smtp/relay_test.go
smtp/server.go

index 05a0e51a4068668357ae0c037784ea9ff831d715..4b21e7af8f393eabd648694d393553df5342581a 100644 (file)
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ the recipient, edit the subject with [sendas:ADDRESS].
 
 ## RFCs
 
-This server implements the following RFCs:
+This server implements (partially) 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)
@@ -33,4 +33,5 @@ This server implements the following RFCs:
 - [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)
+- [Simple Mail Transfer Protocol (SMTP) Service Extension for Delivery Status Notifications (DSNs)](https://tools.ietf.org/html/rfc3461)
 - [POP3 Extension Mechanism, RFC 2449](https://tools.ietf.org/html/rfc2449)
index ec1644e327888edc0d0c605ff8fd8ce69866b7f6..55117999a92eda46f60f37cf65deb6770ed25ef0 100644 (file)
@@ -8,7 +8,6 @@ package smtp
 
 import (
        "bytes"
-       "crypto/rand"
        "crypto/tls"
        "encoding/base64"
        "fmt"
@@ -412,7 +411,7 @@ func (conn *connection) doDATA() {
                MailFrom:   *conn.mailFrom,
                RcptTo:     conn.rcptTo,
                Received:   received,
-               ID:         conn.envelopeID(received),
+               ID:         generateEnvelopeId("m", received),
                Data:       data,
        }
 
@@ -510,12 +509,6 @@ func (conn *connection) handleSendAs(env *Envelope) {
        env.MailFrom.Address = sendAsAddress
 }
 
-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) getReceivedInfo(envelope Envelope) []byte {
        rhost, _, err := net.SplitHostPort(conn.remoteAddr.String())
        if err != nil {
index dc26f191bd9f65427191bd196b217ce6add0df84..53ebe1562136773802259183795ab9ea683c103e 100644 (file)
@@ -7,9 +7,15 @@
 package smtp
 
 import (
+       "bytes"
        "crypto/tls"
+       "fmt"
+       "mime/multipart"
        "net"
+       "net/mail"
        "net/smtp"
+       "net/textproto"
+       "time"
 
        "go.uber.org/zap"
 )
@@ -21,9 +27,7 @@ func RelayMessage(server Server, env Envelope, log *zap.Logger) {
                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)
+                       deliverRelayFailure(server, env, log, rcptTo.Address, "failed to lookup MX records", err)
                        return
                }
                host := mx[0].Host + ":25"
@@ -33,69 +37,125 @@ func RelayMessage(server Server, env Envelope, log *zap.Logger) {
 
 func relayMessageToHost(server Server, env Envelope, log *zap.Logger, to, host string) {
        from := env.MailFrom.Address
+       log = log.With(zap.String("host", host))
 
        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)
+               deliverRelayFailure(server, env, log, to, "failed to dial host", 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)
+               deliverRelayFailure(server, env, log, to, "failed to HELO", 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)
+                       deliverRelayFailure(server, env, log, to, "failed to STARTTLS", err)
                        return
                }
        }
 
        if err = c.Mail(from); err != nil {
-               log.Error("failed MAIL FROM", zap.Error(err))
-               deliverRelayFailure(env, err)
+               deliverRelayFailure(server, env, log, to, "failed MAIL FROM", err)
                return
        }
 
        if err = c.Rcpt(to); err != nil {
-               log.Error("failed to RCPT TO", zap.Error(err))
-               deliverRelayFailure(env, err)
+               deliverRelayFailure(server, env, log, to, "failed to RCPT TO", err)
                return
        }
 
        wc, err := c.Data()
        if err != nil {
-               log.Error("failed to DATA", zap.Error(err))
-               deliverRelayFailure(env, err)
+               deliverRelayFailure(server, env, log, to, "failed to DATA", err)
                return
        }
 
        _, err = wc.Write(env.Data)
        if err != nil {
                wc.Close()
-               log.Error("failed to write DATA", zap.Error(err))
-               deliverRelayFailure(env, err)
+               deliverRelayFailure(server, env, log, to, "failed to write DATA", err)
                return
        }
 
        if err = wc.Close(); err != nil {
-               log.Error("failed to close DATA", zap.Error(err))
-               deliverRelayFailure(env, err)
+               deliverRelayFailure(server, env, log, to, "failed to close DATA", err)
                return
        }
 }
 
-func deliverRelayFailure(env Envelope, err error) {
-       // TODO: constructo a delivery status notification
+// deliverRelayFailure logs and generates a delivery status notification. It
+// writes to |log| the |errorStr| and |sendErr|, as well as preparing a new
+// message, based of |env|, delivered to |server| that reports error
+// information about the attempted delivery.
+func deliverRelayFailure(server Server, env Envelope, log *zap.Logger, to, errorStr string, sendErr error) {
+       log.Error(errorStr, zap.Error(sendErr))
+
+       buf := &bytes.Buffer{}
+       mw := multipart.NewWriter(buf)
+
+       now := time.Now()
+
+       failure := Envelope{
+               MailFrom: mail.Address{"mailpopbox", "mailbox@" + DomainForAddress(env.MailFrom)},
+               RcptTo:   []mail.Address{env.MailFrom},
+               ID:       generateEnvelopeId("f", now),
+               Received: now,
+       }
+
+       fmt.Fprintf(buf, "From: %s\n", failure.MailFrom.String())
+       fmt.Fprintf(buf, "To: %s\n", failure.RcptTo[0].String())
+       fmt.Fprintf(buf, "Subject: Delivery Status Notification (Failure)\n")
+       fmt.Fprintf(buf, "X-Failed-Recipients: %s\n", to)
+       fmt.Fprintf(buf, "Message-ID: %s\n", failure.ID)
+       fmt.Fprintf(buf, "Date: %s\n", now.Format(time.RFC1123Z))
+       fmt.Fprintf(buf, "Content-Type: multipart/report; boundary=%s; report-type=delivery-status\n\n", mw.Boundary())
+
+       tw, err := mw.CreatePart(textproto.MIMEHeader{
+               "Content-Type": []string{"text/plain; charset=UTF-8"},
+       })
+       if err != nil {
+               log.Error("failed to create multipart 0", zap.Error(err))
+               return
+       }
+       fmt.Fprintf(tw, "* * * Delivery Failure * * *\n\n")
+       fmt.Fprintf(tw, "The server failed to relay the message:\n\n%s:\n%s\n", errorStr, sendErr.Error())
+
+       sw, err := mw.CreatePart(textproto.MIMEHeader{
+               "Content-Type": []string{"delivery-status"},
+       })
+       if err != nil {
+               log.Error("failed to create multipart 1", zap.Error(err))
+               return
+       }
+       fmt.Fprintf(sw, "Original-Envelope-ID: %s\n", env.ID)
+       fmt.Fprintf(sw, "Reporting-UA: %s\n", env.EHLO)
+       if env.RemoteAddr != nil {
+               rhosts, err := net.LookupAddr(env.RemoteAddr.String())
+               if err == nil {
+                       fmt.Fprintf(sw, "Reporting-MTA: %s\n", rhosts[0])
+               }
+               fmt.Fprintf(sw, "X-Remote-Address: %s\n", env.RemoteAddr)
+       }
+       fmt.Fprintf(sw, "Date: %s\n", env.Received.Format(time.RFC1123Z))
+
+       ocw, err := mw.CreatePart(textproto.MIMEHeader{
+               "Content-Type": []string{"message/rfc822"},
+       })
+       if err != nil {
+               log.Error("failed to create multipart 2", zap.Error(err))
+               return
+       }
+
+       ocw.Write(env.Data)
+
+       mw.Close()
+
+       failure.Data = buf.Bytes()
+       server.OnMessageDelivered(failure)
 }
index 31d0d2231cf761fdb5363f3806859f31a246e71f..e9f0c15cbd8235428a07b348cd66f9ce5cfcff18 100644 (file)
@@ -8,7 +8,13 @@ package smtp
 
 import (
        "bytes"
+       "fmt"
+       "io/ioutil"
+       "mime"
+       "mime/multipart"
+       "net"
        "net/mail"
+       "strings"
        "testing"
 
        "go.uber.org/zap"
@@ -62,3 +68,158 @@ func TestRelayRoundTrip(t *testing.T) {
                t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", string(env.Data), string(received.Data))
        }
 }
+
+func TestDeliveryFailureMessage(t *testing.T) {
+       s := &deliveryServer{}
+
+       env := Envelope{
+               MailFrom:   mail.Address{Address: "from@sender.org"},
+               RcptTo:     []mail.Address{{Address: "to@receive.net"}},
+               Data:       []byte("Message\n"),
+               ID:         "m.willfail",
+               EHLO:       "mx.receive.net",
+               RemoteAddr: &net.IPAddr{net.IPv4(127, 0, 0, 1), ""},
+       }
+
+       errorStr1 := "internal message"
+       errorStr2 := "general error 122"
+       deliverRelayFailure(s, env, zap.NewNop(), env.RcptTo[0].Address, errorStr1, fmt.Errorf(errorStr2))
+
+       if len(s.messages) != 1 {
+               t.Errorf("Expected 1 failure notification, got %d", len(s.messages))
+               return
+       }
+
+       failure := s.messages[0]
+
+       if failure.RcptTo[0].Address != env.MailFrom.Address {
+               t.Errorf("Failure message should be delivered to sender %s, actually %s", env.MailFrom.Address, failure.RcptTo[0].Address)
+       }
+
+       // Read the failure message.
+       buf := bytes.NewBuffer(failure.Data)
+       msg, err := mail.ReadMessage(buf)
+       if err != nil {
+               t.Errorf("Failed to read message: %v", err)
+               return
+       }
+
+       // Parse out the Content-Type to get the multipart boundary string.
+       mediatype, mtheaders, err := mime.ParseMediaType(msg.Header["Content-Type"][0])
+       if err != nil {
+               t.Errorf("Failed to parse MIME headers: %v", err)
+               return
+       }
+
+       expected := "multipart/report"
+       if mediatype != expected {
+               t.Errorf("Expected MIME type of %q, got %q", expected, mediatype)
+       }
+
+       expected = "delivery-status"
+       if mtheaders["report-type"] != expected {
+               t.Errorf("Expected report-type of %q, got %q", expected, mtheaders["report-type"])
+       }
+
+       boundary := mtheaders["boundary"]
+
+       expected = "Delivery Status Notification (Failure)"
+       if msg.Header["Subject"][0] != expected {
+               t.Errorf("Subject did not match %q, got %q", expected, mtheaders["Subject"])
+       }
+
+       if msg.Header["To"][0] != "<"+env.MailFrom.Address+">" {
+               t.Errorf("To field does not match %q, got %q", env.MailFrom.Address, msg.Header["To"][0])
+       }
+
+       // Parse the multi-part messsage.
+       mpr := multipart.NewReader(msg.Body, boundary)
+       part, err := mpr.NextPart()
+       if err != nil {
+               t.Errorf("Error reading part 0: %v", err)
+               return
+       }
+
+       // First part is the human-readable error.
+       expected = "text/plain; charset=UTF-8"
+       if part.Header["Content-Type"][0] != expected {
+               t.Errorf("Part 0 type expected %q, got %q", expected, part.Header["Content-Type"][0])
+       }
+
+       content, err := ioutil.ReadAll(part)
+       if err != nil {
+               t.Errorf("Failed to read part 0 content: %v", err)
+               return
+       }
+       contentStr := string(content)
+
+       if !strings.Contains(contentStr, "Delivery Failure") {
+               t.Errorf("Missing Delivery Failure")
+       }
+
+       expected = fmt.Sprintf("%s:\n%s", errorStr1, errorStr2)
+       if !strings.Contains(contentStr, expected) {
+               t.Errorf("Missing error string %q", expected)
+       }
+
+       // Second part is the status information.
+       part, err = mpr.NextPart()
+       if err != nil {
+               t.Errorf("Error reading part 1: %v", err)
+               return
+       }
+
+       expected = "delivery-status"
+       if part.Header["Content-Type"][0] != expected {
+               t.Errorf("Part 1 type expected %q, got %q", expected, part.Header["Content-Type"][0])
+       }
+
+       content, err = ioutil.ReadAll(part)
+       if err != nil {
+               t.Errorf("Failed to read part 1 content: %v", err)
+               return
+       }
+       contentStr = string(content)
+
+       expected = "Original-Envelope-ID: " + env.ID + "\n"
+       if !strings.Contains(contentStr, expected) {
+               t.Errorf("Missing %q in %q", expected, contentStr)
+       }
+
+       expected = "Reporting-UA: " + env.EHLO + "\n"
+       if !strings.Contains(contentStr, expected) {
+               t.Errorf("Missing %q in %q", expected, contentStr)
+       }
+
+       expected = "Reporting-MTA: localhost\n"
+       if !strings.Contains(contentStr, expected) {
+               t.Errorf("Missing %q in %q", expected, contentStr)
+       }
+
+       expected = "X-Remote-Address: 127.0.0.1\n"
+       if !strings.Contains(contentStr, expected) {
+               t.Errorf("Missing %q in %q", expected, contentStr)
+       }
+
+       // Third part is the original message.
+       part, err = mpr.NextPart()
+       if err != nil {
+               t.Errorf("Error reading part 2: %v", err)
+               return
+       }
+
+       expected = "message/rfc822"
+       if part.Header["Content-Type"][0] != expected {
+               t.Errorf("Part 2 type expected %q, got %q", expected, part.Header["Content-Type"][0])
+       }
+
+       content, err = ioutil.ReadAll(part)
+       if err != nil {
+               t.Errorf("Failed to read part 2 content: %v", err)
+               return
+       }
+
+       if !bytes.Equal(content, env.Data) {
+               t.Errorf("Byte content of original message does not match")
+       }
+}
index 14002c5be2ba28793fa719edda77b58a47f5bfc4..3b0fdfa8bf6f5f9835b97b09203d21b64f12c65a 100644 (file)
@@ -7,12 +7,13 @@
 package smtp
 
 import (
+       "crypto/rand"
        "crypto/tls"
-       "regexp"
        "fmt"
        "io"
        "net"
        "net/mail"
+       "regexp"
        "strings"
        "time"
 )
@@ -64,6 +65,12 @@ func WriteEnvelopeForDelivery(w io.Writer, e Envelope) {
        w.Write(e.Data)
 }
 
+func generateEnvelopeId(prefix string, t time.Time) string {
+       var idBytes [4]byte
+       rand.Read(idBytes[:])
+       return fmt.Sprintf("%s.%d.%x", prefix, t.UnixNano(), idBytes)
+}
+
 type Server interface {
        Name() string
        TLSConfig() *tls.Config