Some checks failed
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>
701 lines
20 KiB
Go
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")
|
|
}
|