Files
notify-gateway/internal/outbound/smtp_test.go
T
2026-09-16 06:26:16 +02:00

136 lines
4.1 KiB
Go

package outbound
import (
"context"
"crypto/tls"
"encoding/base64"
"io"
"net"
"net/mail"
"net/textproto"
"strconv"
"strings"
"testing"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/testutil"
)
func smtpFixture(t *testing.T, mode string, rcptCode int) (config.OutboundConfig, *tls.Config, <-chan []byte) {
t.Helper()
serverTLS, clientTLS := testutil.TLSConfigs()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
received := make(chan []byte, 1)
done := make(chan struct{})
go func() {
defer close(done)
raw, err := listener.Accept()
if err != nil {
return
}
defer raw.Close()
var conn net.Conn = raw
if mode == "tls" {
conn = tls.Server(raw, serverTLS)
}
tp := textproto.NewConn(conn)
tp.PrintfLine("220 test SMTP")
for {
line, err := tp.ReadLine()
if err != nil {
return
}
verb := strings.Split(line, " ")[0]
switch verb {
case "EHLO":
if mode == "starttls" {
tp.PrintfLine("250-test\r\n250-STARTTLS\r\n250 AUTH PLAIN")
} else {
tp.PrintfLine("250-test\r\n250 AUTH PLAIN")
}
case "STARTTLS":
tp.PrintfLine("220 upgrade")
conn = tls.Server(raw, serverTLS)
tp = textproto.NewConn(conn)
case "AUTH":
data, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(line, "AUTH PLAIN "))
if string(data) != "\x00user\x00password" {
t.Error("unexpected SMTP auth")
}
tp.PrintfLine("235 OK")
case "MAIL":
tp.PrintfLine("250 OK")
case "RCPT":
tp.PrintfLine("%d recipient", rcptCode)
case "DATA":
tp.PrintfLine("354 data")
b, err := tp.ReadDotBytes()
if err != nil {
return
}
received <- b
tp.PrintfLine("250 accepted")
case "QUIT":
return // A lost QUIT response must still count as success.
default:
tp.PrintfLine("500 unsupported")
}
}
}()
t.Cleanup(func() { listener.Close(); <-done })
host, port, _ := net.SplitHostPort(listener.Addr().String())
p, _ := strconv.Atoi(port)
return config.OutboundConfig{ID: "mail", Provider: "smtp", SMTPHost: host, SMTPPort: p, TLSMode: mode, Username: "user", Password: "password", From: "sender@example.org", To: []string{"to@example.org"}, Live: true, TimeoutS: 2}, clientTLS, received
}
func TestSMTPEncryptedDeliveryAndMIME(t *testing.T) {
for _, mode := range []string{"tls", "starttls"} {
t.Run(mode, func(t *testing.T) {
o, tlsConfig, received := smtpFixture(t, mode, 250)
r, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"title": "Grüße\r\nBcc: bad@example.org", "message": "Hallo Welt äöü"}, "stable-id", tlsConfig)
if err != nil || r.StatusCode != 250 {
t.Fatalf("%+v %v", r, err)
}
msg, err := mail.ReadMessage(strings.NewReader(string(<-received)))
if err != nil {
t.Fatal(err)
}
if msg.Header.Get("Bcc") != "" || msg.Header.Get("Message-ID") != "<stable-id@notify-gateway.local>" || msg.Header.Get("Auto-Submitted") != "auto-generated" {
t.Fatal("unsafe/missing headers")
}
body, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, msg.Body))
if err != nil || string(body) != "Hallo Welt äöü" {
t.Fatalf("body=%s %v", body, err)
}
})
}
}
func TestSMTPFailures(t *testing.T) {
for _, code := range []int{450, 550} {
t.Run(strconv.Itoa(code), func(t *testing.T) {
o, tlsConfig, _ := smtpFixture(t, "tls", code)
r, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"message": "test"}, "id", tlsConfig)
if err == nil || r.StatusCode != code || r.Retryable != (code == 450) {
t.Fatalf("%+v %v", r, err)
}
})
}
t.Run("untrusted certificate", func(t *testing.T) {
o, _, _ := smtpFixture(t, "tls", 250)
if _, err := sendSMTP(context.Background(), o, map[string]any{"message": "test"}, "id"); err == nil {
t.Fatal("untrusted certificate accepted")
}
})
t.Run("no STARTTLS", func(t *testing.T) {
o, tlsConfig, _ := smtpFixture(t, "plain", 250)
o.TLSMode = "starttls"
if _, err := sendSMTPWithTLS(context.Background(), o, map[string]any{"message": "test"}, "id", tlsConfig); err == nil {
t.Fatal("plaintext SMTP accepted")
}
})
}