package sender import ( "bufio" "context" "crypto/hmac" "crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement. "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/pem" "errors" "math/big" "net" "strings" "testing" "time" "github.com/stretchr/testify/require" "rocketgit.ru/rsmon/worker/internal/wire" ) type fakeSMTPServer struct { listener net.Listener done chan error tls *tls.Config } func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)} go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }() t.Cleanup(func() { _ = listener.Close() select { case err := <-server.done: require.NoError(t, err) case <-time.After(time.Second): t.Error("fake SMTP server did not exit") } }) return server } func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error { conn, err := s.listener.Accept() if err != nil { return nil } defer conn.Close() //nolint:errcheck if implicitTLS { conn = tls.Server(conn, s.tls) if err := conn.(*tls.Conn).Handshake(); err != nil { return err } } reader := bufio.NewReader(conn) writer := bufio.NewWriter(conn) if err := smtpReply(writer, "220 fake smtp"); err != nil { return err } line, err := smtpCommand(reader) if err != nil { return err } if !strings.HasPrefix(line, "EHLO ") { return errors.New("expected EHLO") } if blockEHLO { _, _ = reader.ReadByte() return nil } capabilities := []string{"250-fake"} if startTLS { capabilities = append(capabilities, "250-STARTTLS") } if auth != "" { capabilities = append(capabilities, "250-AUTH "+auth) } capabilities = append(capabilities, "250 OK") if err := smtpReplies(writer, capabilities...); err != nil { return err } if startTLS { if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" { return errors.New("expected STARTTLS") } if err := smtpReply(writer, "220 ready for TLS"); err != nil { return err } conn = tls.Server(conn, s.tls) if err := conn.(*tls.Conn).Handshake(); err != nil { return err } reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn) if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") { return errors.New("expected EHLO after STARTTLS") } capabilities = []string{"250-fake"} if auth != "" { capabilities = append(capabilities, "250-AUTH "+auth) } capabilities = append(capabilities, "250 OK") if err := smtpReplies(writer, capabilities...); err != nil { return err } } if err := smtpAuthenticate(reader, writer, auth); err != nil { return err } if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:") { return errors.New("expected MAIL FROM") } if err := smtpReply(writer, "250 sender ok"); err != nil { return err } if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:") { return errors.New("expected RCPT TO") } if err := smtpReply(writer, "250 recipient ok"); err != nil { return err } if line, err = smtpCommand(reader); err != nil || line != "DATA" { return errors.New("expected DATA") } if err := smtpReply(writer, "354 send data"); err != nil { return err } dataLines := 0 for { line, err = smtpCommand(reader) if err != nil { return err } if line == "." { break } dataLines++ } if dataLines == 0 { return errors.New("expected non-empty DATA payload") } if err := smtpReply(writer, "250 queued"); err != nil { return err } line, err = smtpCommand(reader) if err != nil || line != "QUIT" { return errors.New("expected QUIT") } return smtpReply(writer, "221 bye") } func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error { if auth == "" { return nil } line, err := smtpCommand(reader) if err != nil { return err } if strings.Contains(auth, "CRAM-MD5") { if line != "AUTH CRAM-MD5" { return errors.New("expected capability-selected CRAM-MD5 authentication") } challenge := []byte("fake-cram-challenge") if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil { return err } response, err := smtpCommand(reader) if err != nil { return err } decoded, err := base64.StdEncoding.DecodeString(response) if err != nil { return err } mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement. _, _ = mac.Write(challenge) if string(decoded) != "user "+fmtHex(mac.Sum(nil)) { return errors.New("invalid CRAM-MD5 response") } return smtpReply(writer, "235 authenticated") } if line != "AUTH LOGIN" { return errors.New("expected capability-selected LOGIN authentication") } if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil { return err } if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) { return errors.New("invalid LOGIN username") } if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil { return err } if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) { return errors.New("invalid LOGIN password") } return smtpReply(writer, "235 authenticated") } func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) { server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN") cred := fakeSMTPCredential(t, server.listener.Addr().String()) require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "body", cred, true)) } func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) { server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN") cred := fakeSMTPCredential(t, server.listener.Addr().String()) require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "body", cred)) } func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) { server := newFakeSMTPServer(t, false, false, true, "") cred := fakeSMTPCredential(t, server.listener.Addr().String()) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) defer cancel() err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "body", cred) require.ErrorIs(t, err, context.DeadlineExceeded) } func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential { t.Helper() host, port, err := net.SplitHostPort(address) require.NoError(t, err) portNumber, err := net.LookupPort("tcp", port) require.NoError(t, err) return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true} } func fakeSMTPTLSConfig(t *testing.T) *tls.Config { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) require.NoError(t, err) certificate, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) require.NoError(t, err) return &tls.Config{Certificates: []tls.Certificate{certificate}} } func smtpCommand(reader *bufio.Reader) (string, error) { line, err := reader.ReadString('\n') return strings.TrimRight(line, "\r\n"), err } func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) } func smtpReplies(writer *bufio.Writer, lines ...string) error { for _, line := range lines { if _, err := writer.WriteString(line + "\r\n"); err != nil { return err } } return writer.Flush() } func fmtHex(value []byte) string { const hex = "0123456789abcdef" result := make([]byte, len(value)*2) for i, b := range value { result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f] } return string(result) }