From 21085a91f83034a9735f57d3903c5c236c84ce4e Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Sat, 6 Jun 2020 12:06:32 -0400 Subject: [PATCH] Produce delivery-status failure notifications when failing to relay a message. --- README.md | 3 +- smtp/conn.go | 9 +-- smtp/relay.go | 110 ++++++++++++++++++++++++------- smtp/relay_test.go | 161 +++++++++++++++++++++++++++++++++++++++++++++ smtp/server.go | 9 ++- 5 files changed, 257 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 05a0e51..4b21e7a 100644 --- 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) diff --git a/smtp/conn.go b/smtp/conn.go index ec1644e..5511799 100644 --- a/smtp/conn.go +++ b/smtp/conn.go @@ -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 { diff --git a/smtp/relay.go b/smtp/relay.go index dc26f19..53ebe15 100644 --- a/smtp/relay.go +++ b/smtp/relay.go @@ -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) } diff --git a/smtp/relay_test.go b/smtp/relay_test.go index 31d0d22..e9f0c15 100644 --- a/smtp/relay_test.go +++ b/smtp/relay_test.go @@ -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") + } +} diff --git a/smtp/server.go b/smtp/server.go index 14002c5..3b0fdfa 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -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 -- 2.22.5