## 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)
- [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)
import (
"bytes"
- "crypto/rand"
"crypto/tls"
"encoding/base64"
"fmt"
MailFrom: *conn.mailFrom,
RcptTo: conn.rcptTo,
Received: received,
- ID: conn.envelopeID(received),
+ ID: generateEnvelopeId("m", received),
Data: data,
}
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 {
package smtp
import (
+ "bytes"
"crypto/tls"
+ "fmt"
+ "mime/multipart"
"net"
+ "net/mail"
"net/smtp"
+ "net/textproto"
+ "time"
"go.uber.org/zap"
)
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"
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)
}
import (
"bytes"
+ "fmt"
+ "io/ioutil"
+ "mime"
+ "mime/multipart"
+ "net"
"net/mail"
+ "strings"
"testing"
"go.uber.org/zap"
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")
+ }
+}
package smtp
import (
+ "crypto/rand"
"crypto/tls"
- "regexp"
"fmt"
"io"
"net"
"net/mail"
+ "regexp"
"strings"
"time"
)
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