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:\r\n") probe.expect("250") probe.send("RCPT TO:\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") }