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") != "" || 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") } }) }