package email import ( "context" "crypto/tls" "crypto/x509" "encoding/base64" "fmt" "io" "mime" "mime/multipart" "net" "net/http/httptest" "net/mail" "net/textproto" "strconv" "strings" "sync/atomic" "testing" "time" "git.sendico.io/tech/sxchnge/notifications/model" ) type smtpFixture struct { settings smtpSettings root *x509.CertPool body chan []byte auth atomic.Bool } func smtpServer(t *testing.T, reject string, code int) *smtpFixture { t.Helper() certServer := httptest.NewTLSServer(nil) cert := certServer.TLS.Certificates[0] root := x509.NewCertPool() root.AddCert(certServer.Certificate()) certServer.Close() listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = listener.Close() }) host, port, _ := net.SplitHostPort(listener.Addr().String()) n, _ := strconv.Atoi(port) f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)} go func() { conn, err := listener.Accept() if err != nil { return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) if reject == "greeting" { _ = conn.(*tls.Conn).Handshake() _, _ = io.Copy(io.Discard, conn) return } c := textproto.NewConn(conn) if c.PrintfLine("220 localhost ESMTP") != nil { return } for { line, err := c.ReadLine() if err != nil { return } command := strings.SplitN(line, " ", 2)[0] if command == reject { _ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code) continue } switch command { case "EHLO": _ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN") case "AUTH": want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password")) f.auth.Store(line == want) _ = c.PrintfLine("235 authenticated") case "MAIL", "RCPT": _ = c.PrintfLine("250 OK") case "DATA": _ = c.PrintfLine("354 send message") body, err := c.ReadDotBytes() if err != nil { return } f.body <- body if reject == "final" { _ = c.PrintfLine("%d final rejection", code) } else if reject == "disconnect" { return } else { _ = c.PrintfLine("250 queued") } case "QUIT": // A lost QUIT reply must not turn accepted DATA into UNKNOWN. return default: _ = c.PrintfLine("500 unexpected command") } } }() return f } func (f *smtpFixture) sender() *smtpSender { return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}} } func TestSMTPAcceptanceAndMIME(t *testing.T) { f := smtpServer(t, "", 0) sender := f.sender() email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "

Confirm code 123456

"} out := sender.Send(context.Background(), "notification-id", email) if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() { t.Fatal("SMTP acceptance/authentication failed", out) } msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body))) if err != nil { t.Fatal(err) } subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject")) if err != nil || subject != email.Subject { t.Fatal("wrong MIME subject", subject, err) } if msg.Header.Get("From") != "\"SXCHNGE\" " || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") { t.Fatal("invalid sender/message identifier") } kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) if err != nil || kind != "multipart/alternative" { t.Fatal("wrong MIME type", kind, err) } parts := multipart.NewReader(msg.Body, params["boundary"]) for _, want := range []string{email.Text, email.HTML} { part, err := parts.NextPart() if err != nil { t.Fatal(err) } body, err := io.ReadAll(part) if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want { t.Fatal("incorrect MIME content", err) } } } func TestSMTPFailureSafety(t *testing.T) { for _, tc := range []struct { stage string code int state model.State failure string }{ {"AUTH", 535, model.Failed, "provider_authentication_failed"}, {"RCPT", 550, model.Failed, "provider_rejected"}, {"final", 451, model.RetryWait, "provider_temporarily_unavailable"}, {"disconnect", 0, model.Unknown, "provider_outcome_unknown"}, } { t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) { f := smtpServer(t, tc.stage, tc.code) out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"}) if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code { t.Fatal("incorrect SMTP classification", out) } if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") { t.Fatal("SMTP rejection leaked private data") } if tc.code == 451 && out.RetryAfter <= 0 { t.Fatal("temporary rejection did not schedule retry") } }) } } func TestSMTPCertificateAndCancellation(t *testing.T) { t.Run("untrusted certificate", func(t *testing.T) { f := smtpServer(t, "", 0) sender := f.sender() sender.tls.RootCAs = x509.NewCertPool() if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() { t.Fatal("untrusted TLS accepted", out) } }) t.Run("cancel blocked greeting", func(t *testing.T) { f := smtpServer(t, "greeting", 0) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() started := time.Now() if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second { t.Fatal("SMTP ignored cancellation", out) } }) } func TestSMTPConfigAndHeaderValidation(t *testing.T) { t.Setenv("TEST_SMTP_PASSWORD", "test-password") valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10} if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil { t.Fatal(err) } for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} { settings := map[string]any{} for key, item := range valid { settings[key] = item } settings[field] = value if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil { t.Fatalf("invalid setting %s accepted", field) } } if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil { t.Fatal("header injection accepted") } } func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) { f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"} auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a")) out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{}) if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") { t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage) } }