Repository: jhillyerd/enmime
Pull request #182 (2021-02-14 21:20:09)

Add an abstraction layer for sending messages
This PR abstracts the sending of messages via SMTP, allowing users to substitute their own sending routines.  It also lets enmime and its users to test the sending of mail via mocks or fakes.

First step for #158 

*Warning:* This does change the builder API, will require new semantic release.

jhillyerd: Added a reworked version of that Go Bcc comment, and renamed to/from to recipients/reversePath.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dc198f..0f5c6c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](http://semver.org/).
 
 
+## [Unreleased]
+
+### Added
+- `SendWithReversePath` method to builder, allows specifying a reverse-path
+  that differs from the from address (#179, thanks cgroschupp)
+- A `Sender` interface that allows our users to provide their own mail
+  sending routines, or mock them in tests. #182
+
+
 ## [0.8.4] - 2020-12-18
 
 ### Fixed
diff --git a/builder.go b/builder.go
index 1d2bfd2..da29fd0 100644
--- a/builder.go
+++ b/builder.go
@@ -6,7 +6,6 @@ import (
 	"io/ioutil"
 	"mime"
 	"net/mail"
-	"net/smtp"
 	"net/textproto"
 	"os"
 	"path/filepath"
@@ -307,9 +306,8 @@ func (p MailBuilder) Build() (*Part, error) {
 	return root, nil
 }
 
-// SendWithReturnAddress encodes the message and sends it via the SMTP server specified by addr
-// and from. Send uses net/smtp.SendMail, and accepts the same authentication parameters.
-func (p MailBuilder) SendWithReturnAddress(addr, from string, a smtp.Auth) error {
+// SendWithReversePath encodes the message and sends it via the specified Sender.
+func (p MailBuilder) SendWithReversePath(sender Sender, from string) error {
 	buf := &bytes.Buffer{}
 	root, err := p.Build()
 	if err != nil {
@@ -329,13 +327,13 @@ func (p MailBuilder) SendWithReturnAddress(addr, from string, a smtp.Auth) error
 	for _, a := range p.bcc {
 		recips = append(recips, a.Address)
 	}
-	return smtp.SendMail(addr, a, from, recips, buf.Bytes())
+	return sender.Send(from, recips, buf.Bytes())
 }
 
-// Send encodes the message and sends it via the SMTP server specified by addr. Send uses
-// net/smtp.SendMail, and accepts the same authentication parameters.
-func (p MailBuilder) Send(addr string, a smtp.Auth) error {
-	return p.SendWithReturnAddress(addr, p.from.Address, a)
+// Send encodes the message and sends it via the specified Sender, using the address provided to
+// `From()` as the reverse-path.
+func (p MailBuilder) Send(sender Sender) error {
+	return p.SendWithReversePath(sender, p.from.Address)
 }
 
 // Equals uses the reflect package to test two MailBuilder structs for equality, primarily for unit
diff --git a/sender.go b/sender.go
new file mode 100644
index 0000000..1088571
--- /dev/null
+++ b/sender.go
@@ -0,0 +1,33 @@
+package enmime
+
+import "net/smtp"
+
+// Sender provides a method for enmime to send an email.
+type Sender interface {
+	// Sends the provided msg to the specified recipients, providing the specified reverse-path to
+	// the mail server to use for delivery error reporting.
+	//
+	// The message headers should usually include fields such as "From", "To", "Subject", and "Cc".
+	// Sending "Bcc" messages is accomplished by including an email address in the recipients
+	// parameter but not including it in the message headers.
+	Send(reversePath string, recipients []string, msg []byte) error
+}
+
+// SMTPSender is a Sender backed by Go's built-in net/smtp.SendMail function.
+type SMTPSender struct {
+	addr string
+	auth smtp.Auth
+}
+
+var _ Sender = &SMTPSender{}
+
+// NewSMTP creates a new SMTPSender, which uses net/smtp.SendMail, and accepts the same
+// authentication parameters.  If no authentication is required, `auth` may be nil.
+func NewSMTP(addr string, auth smtp.Auth) *SMTPSender {
+	return &SMTPSender{addr, auth}
+}
+
+// Send a message using net/smtp.SendMail.
+func (s *SMTPSender) Send(reversePath string, recipients []string, msg []byte) error {
+	return smtp.SendMail(s.addr, s.auth, reversePath, recipients, msg)
+}


Test patch:
diff --git a/builder_test.go b/builder_test.go
index 719c8a5..66d8486 100644
--- a/builder_test.go
+++ b/builder_test.go
@@ -3,11 +3,9 @@ package enmime_test
 import (
 	"bytes"
 	"net/mail"
-	"net/smtp"
 	"path/filepath"
 	"reflect"
 	"strconv"
-	"strings"
 	"testing"
 	"time"
 
@@ -15,6 +13,19 @@ import (
 	"github.com/jhillyerd/enmime/internal/test"
 )
 
+type mockSender struct {
+	from string
+	to   []string
+	msg  []byte
+}
+
+func (s *mockSender) Send(from string, to []string, msg []byte) error {
+	s.from = from
+	s.to = to
+	s.msg = msg
+	return nil
+}
+
 var addrSlice = []mail.Address{{Name: "name", Address: "addr"}}
 
 func TestBuilderEquals(t *testing.T) {
@@ -927,27 +938,68 @@ func TestBuilderQPHeaders(t *testing.T) {
 }
 
 func TestSend(t *testing.T) {
-	// Satisfy all block of the Send method and use
-	// an intentionally malformed From Address to
-	// elicit an expected error from smtp.SendMail,
-	// which can be type-checked and verified.
-	text := "test text body"
-	html := "test html body"
+	sender := &mockSender{}
+	from := "from@example.com"
+	tos := []string{"to0@example.com", "to1@example.com"}
+	ccs := []string{"cc0@example.com", "cc1@example.com"}
+	bccs := []string{"bcc0@example.com", "bcc1@example.com"}
+	text := []byte("test text body")
+	html := []byte("test html body")
 	a := enmime.Builder().
-		Text([]byte(text)).
-		HTML([]byte(html)).
-		From("name", "foo\rbar").
+		Text(text).
+		HTML(html).
+		From("name", from).
 		Subject("foo").
-		ToAddrs(addrSlice).
-		CCAddrs(addrSlice).
-		BCCAddrs(addrSlice)
-	// Dummy SMTP Authentication
-	auth := smtp.PlainAuth("", "user@example.com", "password", "mail.example.com")
-	err := a.Send("0.0.0.0", auth)
-	if err == nil {
-		t.Fatal("Send() did not return expected error, failed")
+		To("to 0", tos[0]).
+		To("to 1", tos[1]).
+		CC("cc 0", ccs[0]).
+		CC("cc 1", ccs[1]).
+		BCC("bcc 0", bccs[0]).
+		BCC("bcc 1", bccs[1])
+
+	err := a.Send(sender)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+	if sender.from != from {
+		t.Errorf("Got from %q, wanted %q", sender.from, from)
+	}
+	addrs := append([]string{}, tos...)
+	addrs = append(addrs, ccs...)
+	addrs = append(addrs, bccs...)
+	test.DiffStrings(t, sender.to, addrs)
+	if !bytes.Contains(sender.msg, text) {
+		t.Errorf("msg bytes did not contain text body %q", text)
+	}
+	if !bytes.Contains(sender.msg, html) {
+		t.Errorf("msg bytes did not contain html body %q", html)
+	}
+}
+
+func TestSendWithReversePath(t *testing.T) {
+	sender := &mockSender{}
+	ret := "return@example.com"
+	from := "from@example.com"
+	to := "t0@example.com"
+	text := []byte("test text body")
+	a := enmime.Builder().
+		Text(text).
+		From("name", from).
+		Subject("foo").
+		To("to 0", to)
+
+	err := a.SendWithReversePath(sender, ret)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+	if sender.from != ret {
+		// Builder's .From() should not be provided to Sender.Send().
+		t.Errorf("Got from %q, wanted %q", sender.from, ret)
 	}
-	if !strings.Contains(err.Error(), "smtp: A line must not contain CR or LF") {
-		t.Fatalf("Send() did not return expected error, failed: %s", err.Error())
+	test.DiffStrings(t, sender.to, []string{to})
+	if !bytes.Contains(sender.msg, text) {
+		t.Errorf("msg bytes did not contain text body %q", text)
 	}
 }
diff --git a/example_test.go b/example_test.go
index 3b305e9..2a59451 100644
--- a/example_test.go
+++ b/example_test.go
@@ -12,8 +12,11 @@ import (
 
 // ExampleBuilder illustrates how to build and send a MIME encoded message.
 func ExampleBuilder() {
+	// Create an SMTP Sender which relies on Go's built-in net/smtp package.  Advanced users
+	// may provide their own Sender, or mock it in unit tests.
 	smtpHost := "smtp.relay.host:25"
 	smtpAuth := smtp.PlainAuth("", "user", "pw", "host")
+	sender := enmime.NewSMTP(smtpHost, smtpAuth)
 
 	// MailBuilder is (mostly) immutable, each method below returns a new MailBuilder without
 	// modifying the original.
@@ -25,10 +28,10 @@ func ExampleBuilder() {
 
 	// master is immutable, causing each msg below to have a single recipient.
 	msg := master.To("Esteemed Customer", "user1@inbucket.org")
-	msg.Send(smtpHost, smtpAuth)
+	msg.Send(sender)
 
 	msg = master.To("Another Customer", "user2@inbucket.org")
-	msg.Send(smtpHost, smtpAuth)
+	msg.Send(sender)
 }
 
 func ExampleReadEnvelope() {
diff --git a/sender_test.go b/sender_test.go
new file mode 100644
index 0000000..01da312
--- /dev/null
+++ b/sender_test.go
@@ -0,0 +1,25 @@
+package enmime_test
+
+import (
+	"net/smtp"
+	"strings"
+	"testing"
+
+	"github.com/jhillyerd/enmime"
+)
+
+func TestSMTPSend(t *testing.T) {
+	from := "user@example.com"
+	auth := smtp.PlainAuth("", from, "password", "mail.example.com")
+	s := enmime.NewSMTP("0.0.0.0", auth)
+
+	// Satisfy requirements of the Send method and use an intentionally malformed From Address to
+	// elicit an expected error from smtp.SendMail, which can be type-checked and verified.
+	err := s.Send(from+"\rinvalid", []string{"to@example.com"}, []byte("message content"))
+	if err == nil {
+		t.Fatal("Send() returned nil error, wanted one.")
+	}
+	if !strings.Contains(err.Error(), "smtp: A line must not contain CR or LF") {
+		t.Fatalf("Send() did not return expected error, failed: %s", err.Error())
+	}
+}