smtp2shoutrrr/backend_test.go
butterrobot 7565618efd
Some checks failed
CI / goreleaser-lint (push) Successful in 3s
CI / format (push) Successful in 1m47s
CI / lint (push) Successful in 2m7s
CI / test (push) Successful in 13m1s
CI / build (push) Successful in 12m51s
Release / release (push) Failing after 1m7s
bugfix: fix SMTP authentication bypass, remote DoS, and silent delivery failures (#9) (FMG-3)
Three defects in the SMTP backend, all predating the FMG-2 dependency upgrade.

## Authentication bypass

The credential check joined its two comparisons with `&&`, so it only rejected a client that got *both* halves wrong — a correct username with any password authenticated, as did a correct password with any username. Both comparisons now have to pass, and both are evaluated before either is acted on so the timing does not distinguish a wrong username from a wrong password. They compare SHA-256 digests through `subtle.ConstantTimeCompare`, which keeps the length of the configured credential out of the timing as well.

Fixing the comparison alone does not close the hole. go-smtp advertises `AUTH` but leaves enforcement to the backend, so a client could skip `AUTH` entirely and still have its message forwarded. The session now tracks authentication and refuses `MAIL`, `RCPT` and `DATA` without it.

Nor does the gate mean anything while the credentials it checks can be guessed. `SetDefaults` invented `Username = "username"` and `Password = "password"` when the config omitted them, with only a `slog.Warn` — so a `config.toml` naming nothing but a port and its recipients relayed to anyone who tried the obvious pair, for exactly the deployments that had configured the least. `Validate` now rejects unset credentials the way it already rejects an unusable target, and the invented defaults are gone.

**Behavior change:** a client that used to send without issuing `AUTH` now has to be configured with the credentials from `config.toml`, and a deployment that never set them will not start until it does. Both documented in the README.

## Remote denial of service

A malformed `Content-Type` reached `log.Fatalf`, which calls `os.Exit(1)`. One message with an attacker-controlled header terminated the daemon and took every other in-flight message with it. `Body()` now returns the parse error like the rest of the function does.

## Silent notification loss

`Data` logged forwarding errors and returned `250`, so a client whose notification reached no target at all treated the message as delivered and never retried. The reply now reflects what happened:

- `250` — at least one target accepted it. A *partial* failure is logged but still accepted, since a retry redelivers to every target and would duplicate the notification on the ones that already have it. This keeps the existing `TestPartialTargetFailure` contract.
- `451` — no target accepted it. Worth retrying.
- `550 5.6.0` — the message cannot be parsed. No retry can fix the same bytes.
- `550 5.3.5` — the recipient has no notification targets configured. A configuration fault; no retry can add targets. `LoadConfig` blocks such a config, so this is only reachable from one built in code.

## Session transaction state

`Session.Reset` was empty while go-smtp calls it after every message, so recipients accumulated across a connection and the second message on it was also forwarded to the first one's targets.

Fixing that introduced a data race, caught in review: `Reset` runs on the connection goroutine, but go-smtp runs `Data` on its own goroutine for a `BDAT` transfer and does not block `RSET` during one. A mutex now guards the recipients and the authentication flag across `Rcpt`, `Data` and `Reset`, and `Data` snapshots the recipients up front instead of reading the slice while forwarding.

## Validation

`make format`, `make ci-lint` (0 issues), `make test` (`-race`, 94.2% coverage), `make check` and `make build` all pass locally.

Every regression test was confirmed to fail against the code it guards:

- the six original ones against `origin/main` — the malformed `Content-Type` one by killing the test binary outright, which is the reported DoS;
- `TestResetDuringChunkedTransferIsSynchronized` against `da8e8e4`, where `-race` reports `Session.Reset` racing `Session.Data` on all three runs attempted;
- `TestLoadConfigRejectsUnsetCredentials` against `da8e8e4`'s `config.go`.

## Known gaps, not addressed here

- `Body()` returns `("", nil)` for a `multipart/*` with an unusable boundary and for any media type that is neither `multipart/` nor `text/`, so an empty notification is pushed to every target and answered `250`.
- `forwardEmail` breaks after the first matching `[[Recipients]]` entry, so a message addressed to two matching entries notifies only the first and still answers `250`. Pre-existing.
- A message for an address matching no entry, with no `[CatchAll]`, is accepted with `250` and dropped. Rejecting at `RCPT` would be more honest.
- `smtp.ErrAuthRequired` is go-smtp's `502 5.7.0` where RFC 4954 §6 specifies `530 5.7.0`. Left on the library's own constant.

Closes FMG-3

Co-authored-by: Full-Stack Developer <me@fmartingr.com>
Reviewed-on: #9
Reviewed-by: Felipe M <me@fmartingr.com>
2026-09-07 22:27:04 +02:00

701 lines
20 KiB
Go

package smtp2shoutrrr
import (
"bufio"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/smtp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestEmailForwarding(t *testing.T) {
// Start mock ntfy server
notifications := make([]string, 0)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Read body and log
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
notifications = append(notifications, string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure the SMTP server
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Target: "generic+" + mockServer.URL + "/?template=json",
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Test Email\r\n\r\nThis is a test email body"),
)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
// Verify the notification was received
require.Len(t, notifications, 1)
require.Contains(t, notifications[0], "This is a test email body")
}
func TestMultipleTargets(t *testing.T) {
// Track notifications received
notifications := make([]string, 0)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
notifications = append(notifications, string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure with multiple targets
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{
"generic+" + mockServer.URL + "/?template=json&id=1",
"generic+" + mockServer.URL + "/?template=json&id=2",
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Multiple Targets Test\r\n\r\nTest body for multiple targets"),
)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
// Verify both targets received notification
require.Len(t, notifications, 2)
require.Contains(t, notifications[0], "Test body for multiple targets")
require.Contains(t, notifications[1], "Test body for multiple targets")
}
func TestTargetAndTargetsMerging(t *testing.T) {
// Track notifications and targets hit
notificationCount := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_, _ = io.ReadAll(r.Body)
notificationCount++
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure with both Target (deprecated) and Targets
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Target: "generic+" + mockServer.URL + "/?template=json&id=legacy",
Targets: []string{
"generic+" + mockServer.URL + "/?template=json&id=new1",
"generic+" + mockServer.URL + "/?template=json&id=new2",
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Merging Test\r\n\r\nTest body"),
)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
// Verify all 3 targets received notification (1 from Target + 2 from Targets)
require.Equal(t, 3, notificationCount)
}
func TestPartialTargetFailure(t *testing.T) {
// Track successful notifications
successCount := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_, _ = io.ReadAll(r.Body)
// Fail if URL path contains "fail"
if r.URL.Path == "/fail" {
w.WriteHeader(http.StatusInternalServerError)
return
}
successCount++
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure with mix of good and bad targets
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{
"generic+" + mockServer.URL + "/success1?template=json",
"generic+" + mockServer.URL + "/fail?template=json",
"generic+" + mockServer.URL + "/success2?template=json",
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Partial Failure Test\r\n\r\nTest body"),
)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
// Verify that 2 out of 3 targets succeeded
require.Equal(t, 2, successCount)
}
func TestInvalidTargetURLs(t *testing.T) {
// Track notifications
notifications := make([]string, 0)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
notifications = append(notifications, string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Configure with invalid and valid URLs
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{
"://invalid-url", // Invalid URL
"generic+" + mockServer.URL + "/?template=json&id=1", // Valid URL
"ht!tp://bad url with spaces", // Invalid URL
"generic+" + mockServer.URL + "/?template=json&id=2", // Valid URL
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// Send test email
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Invalid URLs Test\r\n\r\nTest body"),
)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
// Verify only valid URLs received notifications (2 out of 4)
require.Len(t, notifications, 2)
}
// Regression: the credential check joined the two comparisons with &&, so it
// only rejected a client that got both halves wrong. A correct username with
// any password — or a correct password with any username — authenticated, and
// anyone who guessed either half could relay to every configured target.
func TestAuthenticationRejectsPartiallyCorrectCredentials(t *testing.T) {
notifications := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
notifications++
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/?template=json"},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
for name, credentials := range map[string][2]string{
"correct username, wrong password": {"testuser", "wrong"},
"wrong username, correct password": {"wrong", "testpass"},
"both wrong": {"wrong", "wrong"},
"empty credentials": {"", ""},
} {
t.Run(name, func(t *testing.T) {
err := smtp.SendMail(
addr,
smtp.PlainAuth("", credentials[0], credentials[1], "localhost"),
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Should Not Arrive\r\n\r\nbody"),
)
require.Error(t, err)
require.Contains(t, err.Error(), "535", "authentication should be refused permanently")
})
}
require.Zero(t, notifications, "a rejected client must not get anything forwarded")
}
// Regression: go-smtp advertises AUTH but leaves enforcement to the backend,
// so before the session tracked it a client could skip AUTH entirely and still
// have its message forwarded.
func TestUnauthenticatedClientCannotSendMail(t *testing.T) {
notifications := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
notifications++
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/?template=json"},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
client, err := smtp.Dial(addr)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Hello("localhost"))
err = client.Mail("sender@example.com")
require.Error(t, err, "the transaction must not start without authentication")
require.Contains(t, err.Error(), "502")
require.Zero(t, notifications)
}
// Regression: a malformed Content-Type reached log.Fatalf, so one message with
// an attacker-controlled header terminated the daemon and took every other
// in-flight message with it.
func TestMalformedContentTypeIsRejectedAndServerKeepsServing(t *testing.T) {
notifications := make([]string, 0)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
notifications = append(notifications, string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/?template=json"},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
auth := smtp.PlainAuth("", config.Username, config.Password, "localhost")
err := smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Malformed\r\nContent-Type: text/plain; charset\r\n\r\nbody"),
)
require.Error(t, err)
require.Contains(t, err.Error(), "550", "an unparseable message should be refused permanently")
err = smtp.SendMail(
addr,
auth,
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Well Formed\r\n\r\nthe server is still up"),
)
require.NoError(t, err)
require.Len(t, notifications, 1)
require.Contains(t, notifications[0], "the server is still up")
}
// Regression: Data logged the forwarding error and returned 250, so a client
// whose notification reached no target at all considered the message delivered,
// dropped it and never retried.
func TestTotalForwardingFailureIsReportedToSender(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusInternalServerError)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{
"generic+" + mockServer.URL + "/one?template=json",
"generic+" + mockServer.URL + "/two?template=json",
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
err := smtp.SendMail(
addr,
smtp.PlainAuth("", config.Username, config.Password, "localhost"),
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Nobody Received This\r\n\r\nbody"),
)
require.Error(t, err)
require.Contains(t, err.Error(), "451", "the sender should be asked to retry")
}
// A partial failure stays a 250: the sender would redeliver to every target,
// duplicating the notification on the ones that already accepted it.
func TestPartialForwardingFailureIsAcceptedFromSender(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
if r.URL.Path == "/fail" {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{
"generic+" + mockServer.URL + "/fail?template=json",
"generic+" + mockServer.URL + "/ok?template=json",
},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
require.NoError(t, smtp.SendMail(
addr,
smtp.PlainAuth("", config.Username, config.Password, "localhost"),
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Reached One Target\r\n\r\nbody"),
))
}
// Regression: Session.Reset was empty, so recipients accumulated across the
// messages of a single connection and the second one was also forwarded to the
// first one's targets.
func TestRecipientsDoNotLeakBetweenMessagesOnOneConnection(t *testing.T) {
received := make(map[string][]string)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
received[r.URL.Path] = append(received[r.URL.Path], string(body))
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"first@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/first?template=json"},
},
{
Addresses: []string{"second@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/second?template=json"},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
client, err := smtp.Dial(addr)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Hello("localhost"))
require.NoError(t, client.Auth(smtp.PlainAuth("", config.Username, config.Password, "localhost")))
for _, recipient := range []string{"first@example.com", "second@example.com"} {
require.NoError(t, client.Mail("sender@example.com"))
require.NoError(t, client.Rcpt(recipient))
w, err := client.Data()
require.NoError(t, err)
_, err = w.Write([]byte("Subject: For " + recipient + "\r\n\r\nbody for " + recipient))
require.NoError(t, err)
require.NoError(t, w.Close())
}
require.NoError(t, client.Quit())
require.Len(t, received["/first"], 1)
require.Contains(t, received["/first"][0], "body for first@example.com")
require.Len(t, received["/second"], 1)
require.Contains(t, received["/second"][0], "body for second@example.com")
}
// smtpProbe drives a raw SMTP conversation. net/smtp cannot issue BDAT, and
// the transfer below needs a chunked one with an RSET pipelined behind it.
type smtpProbe struct {
t *testing.T
conn net.Conn
r *bufio.Reader
}
func dialProbe(t *testing.T, addr string) *smtpProbe {
t.Helper()
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetDeadline(time.Now().Add(30*time.Second)))
probe := &smtpProbe{t: t, conn: conn, r: bufio.NewReader(conn)}
probe.expect("220")
return probe
}
func (p *smtpProbe) send(format string, args ...any) {
p.t.Helper()
_, err := fmt.Fprintf(p.conn, format, args...)
require.NoError(p.t, err)
}
// expect reads one full reply, skipping the continuation lines of a multiline
// one such as the EHLO capability list, and asserts its status code.
func (p *smtpProbe) expect(code string) {
p.t.Helper()
for {
line, err := p.r.ReadString('\n')
require.NoError(p.t, err)
if len(line) > 3 && line[3] == '-' {
continue
}
require.Truef(p.t, strings.HasPrefix(line, code), "expected %s, got %q", code, line)
return
}
}
func (p *smtpProbe) authenticate(username, password string) {
p.t.Helper()
p.send("EHLO localhost\r\n")
p.expect("250")
p.send("AUTH PLAIN %s\r\n",
base64.StdEncoding.EncodeToString([]byte("\x00"+username+"\x00"+password)))
p.expect("235")
}
// Regression: Reset writes the transaction's recipients on the connection
// goroutine, while go-smtp runs Data on its own goroutine for a BDAT transfer
// and does not block RSET during one. Under -race this reported a data race
// between Reset and the forward in Data; it only passes while the transaction
// state stays guarded.
func TestResetDuringChunkedTransferIsSynchronized(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{
Addresses: []string{"test@example.com"},
Targets: []string{"generic+" + mockServer.URL + "/?template=json"},
},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
// The overlap is a few instructions wide, so the transfer is repeated
// rather than attempted once.
for range 30 {
probe := dialProbe(t, addr)
probe.authenticate(config.Username, config.Password)
probe.send("MAIL FROM:<sender@example.com>\r\n")
probe.expect("250")
probe.send("RCPT TO:<test@example.com>\r\n")
probe.expect("250")
// Headers without their terminating blank line, so the transfer
// goroutine parks inside mail.ReadMessage.
const headers = "Subject: Chunked\r\n"
probe.send("BDAT %d\r\n%s", len(headers), headers)
probe.expect("250")
// The blank line releases mail.ReadMessage on the transfer goroutine;
// the RSET pipelined behind it reaches Session.Reset on the connection
// goroutine at the same moment.
probe.send("BDAT 2\r\n\r\nRSET\r\n")
probe.expect("250")
probe.expect("250")
// The connection outlives the aborted transaction.
probe.send("QUIT\r\n")
probe.expect("221")
}
}
// A recipient with no usable targets is a configuration fault, not a delivery
// one: no retry can add targets to a config, so it is refused permanently
// rather than with the 451 the other forwarding failures get. LoadConfig
// blocks such a config, so this is only reachable from one built in code.
func TestRecipientWithoutTargetsIsRejectedPermanently(t *testing.T) {
config := &Config{
Port: 0,
Username: "testuser",
Password: "testpass",
Recipients: []ConfigRecipient{
{Addresses: []string{"test@example.com"}, Targets: []string{"://not-a-url"}},
},
}
addr, _, _ := startServer(t, NewSMTPServer(config))
err := smtp.SendMail(
addr,
smtp.PlainAuth("", config.Username, config.Password, "localhost"),
"sender@example.com",
[]string{"test@example.com"},
[]byte("Subject: Nowhere To Go\r\n\r\nbody"),
)
require.Error(t, err)
require.Contains(t, err.Error(), "550", "a retry cannot add targets to a configuration")
}