diff --git a/backend.go b/backend.go index f1beb203..11c74bca 100644 --- a/backend.go +++ b/backend.go @@ -100,3 +100,13 @@ type AuthSession interface { AuthMechanisms() []string Auth(mech string) (sasl.Server, error) } + +// XCLIENTBackend is an add-on interface for Session. It provides support for the +// XCLIENT extension. +type XCLIENTBackend interface { + // XCLIENT handles the XCLIENT command. The attrs parameter contains + // the connection attributes provided by the client (ADDR, PORT, PROTO, + // HELO, LOGIN, NAME). Backends can validate and process these attributes. + // If an error is returned, the XCLIENT command will be rejected. + XCLIENT(session Session, attrs map[string]string) error +} diff --git a/client.go b/client.go index 21d265cf..1a26c083 100644 --- a/client.go +++ b/client.go @@ -959,3 +959,50 @@ func validateLine(line string) error { } return nil } + +// SupportsXCLIENT checks whether the server supports the XCLIENT extension. +func (c *Client) SupportsXCLIENT() bool { + if err := c.hello(); err != nil { + return false + } + _, ok := c.ext["XCLIENT"] + return ok +} + +// XCLIENT sends the XCLIENT command to the server. +// This is a Postfix-specific extension. +// The client must be on a trusted network for this to work. +// After a successful XCLIENT command, the session is reset and the client +// should send HELO/EHLO again. +func (c *Client) XCLIENT(attrs map[string]string) error { + if err := c.hello(); err != nil { + return err + } + if !c.SupportsXCLIENT() { + return errors.New("smtp: server doesn't support XCLIENT") + } + if len(attrs) == 0 { + return errors.New("smtp: XCLIENT requires at least one attribute") + } + + var parts []string + for k, v := range attrs { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + } + // Sort for deterministic command generation (good for testing) + sort.Strings(parts) + + _, _, err := c.cmd(220, "XCLIENT %s", strings.Join(parts, " ")) + if err != nil { + return err + } + + // After a successful XCLIENT, the server resets the connection and + // sends a new greeting. The client must send HELO/EHLO again. + // We reset the client state to reflect this. + c.didHello = false + c.helloError = nil + c.ext = nil + + return nil +} diff --git a/client_xclient_test.go b/client_xclient_test.go new file mode 100644 index 00000000..37fbf768 --- /dev/null +++ b/client_xclient_test.go @@ -0,0 +1,190 @@ +package smtp + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestClientXCLIENT(t *testing.T) { + xclientServer := "220 hello world\n" + + // Response to first EHLO + "250-mx.google.com at your service\n" + + "250-XCLIENT ADDR PORT PROTO HELO LOGIN NAME\n" + + "250 PIPELINING\n" + + // Response to XCLIENT is a new greeting + "220 new greeting\n" + + // Response to second EHLO + "250-mx.google.com at your service\n" + + "250 PIPELINING\n" + + // Response to QUIT + "221 goodbye" + + xclientClient := "EHLO localhost\n" + + "XCLIENT ADDR=192.168.1.100 PROTO=ESMTP\n" + + "EHLO localhost\n" + // After XCLIENT, session is reset, must say EHLO again + "QUIT" + + server := strings.Join(strings.Split(xclientServer, "\n"), "\r\n") + client := strings.Join(strings.Split(xclientClient, "\n"), "\r\n") + + var wrote bytes.Buffer + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(server), + &wrote, + } + + c := NewClient(fake) + + err := c.Hello("localhost") + if err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if !c.SupportsXCLIENT() { + t.Fatal("Expected server to support XCLIENT") + } + + attrs := map[string]string{ + "ADDR": "192.168.1.100", + "PROTO": "ESMTP", + } + + err = c.XCLIENT(attrs) + if err != nil { + t.Fatalf("XCLIENT failed: %v", err) + } + + // Quit will trigger a new EHLO because the session was reset + if err := c.Quit(); err != nil { + t.Fatalf("Quit failed: %v", err) + } + + actualcmds := wrote.String() + + // Split into lines and verify each command + actualLines := strings.Split(strings.TrimSpace(actualcmds), "\r\n") + expectedLines := strings.Split(strings.TrimSpace(client), "\r\n") + + if len(actualLines) != len(expectedLines) { + t.Fatalf("Got %d lines, expected %d lines.\nGot:\n%s\nExpected:\n%s", len(actualLines), len(expectedLines), actualcmds, client) + } + + for i, actualLine := range actualLines { + expectedLine := expectedLines[i] + + // For XCLIENT command, check that both lines are XCLIENT and contain the same attributes + if strings.HasPrefix(actualLine, "XCLIENT") && strings.HasPrefix(expectedLine, "XCLIENT") { + // Parse attributes from both lines + actualAttrs := parseXCLIENTLine(actualLine) + expectedAttrs := parseXCLIENTLine(expectedLine) + + if len(actualAttrs) != len(expectedAttrs) { + t.Fatalf("XCLIENT line %d: got %d attributes, expected %d", i, len(actualAttrs), len(expectedAttrs)) + } + + for k, v := range expectedAttrs { + if actualAttrs[k] != v { + t.Fatalf("XCLIENT line %d: attribute %s got %q, expected %q", i, k, actualAttrs[k], v) + } + } + } else if actualLine != expectedLine { + t.Fatalf("Line %d: got %q, expected %q", i+1, actualLine, expectedLine) + } + } +} + +// Helper function to parse XCLIENT attributes from a command line +func parseXCLIENTLine(line string) map[string]string { + attrs := make(map[string]string) + parts := strings.Fields(line) + if len(parts) > 1 && parts[0] == "XCLIENT" { + for _, part := range parts[1:] { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + attrs[kv[0]] = kv[1] + } + } + } + return attrs +} + +func TestClientXCLIENT_NotSupported(t *testing.T) { + server := "220 hello world\r\n" + + "250-mx.google.com at your service\r\n" + + "250 PIPELINING\r\n" + + var wrote bytes.Buffer + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(server), + &wrote, + } + + c := NewClient(fake) + + err := c.Hello("localhost") + if err != nil { + t.Fatalf("Hello failed: %v", err) + } + + if c.SupportsXCLIENT() { + t.Fatal("Expected server to not support XCLIENT") + } + + attrs := map[string]string{ + "ADDR": "192.168.1.100", + } + + err = c.XCLIENT(attrs) + if err == nil { + t.Fatal("Expected XCLIENT to fail when not supported") + } + + expectedError := "smtp: server doesn't support XCLIENT" + if err.Error() != expectedError { + t.Fatalf("Expected error %q, got %q", expectedError, err.Error()) + } +} + +func TestClientXCLIENT_EmptyAttributes(t *testing.T) { + server := "220 hello world\r\n" + + "250-mx.google.com at your service\r\n" + + "250-XCLIENT ADDR PORT PROTO HELO LOGIN NAME\r\n" + + "250 PIPELINING\r\n" + + var wrote bytes.Buffer + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(server), + &wrote, + } + + c := NewClient(fake) + + err := c.Hello("localhost") + if err != nil { + t.Fatalf("Hello failed: %v", err) + } + + err = c.XCLIENT(map[string]string{}) + if err == nil { + t.Fatal("Expected XCLIENT to fail with empty attributes") + } + + expectedError := "smtp: XCLIENT requires at least one attribute" + if err.Error() != expectedError { + t.Fatalf("Expected error %q, got %q", expectedError, err.Error()) + } +} diff --git a/conn.go b/conn.go index ff43e0ed..9642e1f8 100644 --- a/conn.go +++ b/conn.go @@ -44,6 +44,9 @@ type Conn struct { fromReceived bool recipients []string didAuth bool + + // XCLIENT data - stores connection information provided by XCLIENT command + xclientData map[string]string } func newConn(c net.Conn, s *Server) *Conn { @@ -144,6 +147,8 @@ func (c *Conn) handle(cmd string, arg string) { c.handleAuth(arg) case "STARTTLS": c.handleStartTLS() + case "XCLIENT": + c.handleXCLIENT(arg) default: msg := fmt.Sprintf("Syntax errors, %v command unrecognized", cmd) c.protocolError(500, EnhancedCode{5, 5, 2}, msg) @@ -308,6 +313,9 @@ func (c *Conn) handleGreet(enhanced bool, arg string) { caps = append(caps, fmt.Sprintf("MT-PRIORITY %s", c.server.MtPriorityProfile)) } } + if c.server.EnableXCLIENT && c.isXCLIENTTrusted() { + caps = append(caps, "XCLIENT ADDR PORT PROTO HELO LOGIN NAME") + } args := []string{"Hello " + domain} args = append(args, caps...) @@ -778,8 +786,25 @@ func (c *Conn) handleRcpt(arg string) { } opts.MTPriority = &mtPriority default: - c.writeResponse(500, EnhancedCode{5, 5, 4}, "Unknown RCPT TO argument") - return + // Handle custom extensions (non-standard parameters) + if !c.server.EnableRCPTExtensions { + c.writeResponse(500, EnhancedCode{5, 5, 4}, "Unknown RCPT parameter") + return + } + + if opts.Extensions == nil { + opts.Extensions = make(map[string]string) + } + + // Special validation for XRCPTFORWARD + if key == "XRCPTFORWARD" { + if err := c.validateXRCPTFORWARD(value); err != nil { + c.writeResponse(501, EnhancedCode{5, 5, 4}, fmt.Sprintf("Malformed XRCPTFORWARD parameter: %v", err)) + return + } + } + + opts.Extensions[key] = value } } @@ -1330,6 +1355,302 @@ func (c *Conn) readLine() (string, error) { return c.text.ReadLine() } +// validateXRCPTFORWARD validates an XRCPTFORWARD parameter value according to Dovecot specification +func (c *Conn) validateXRCPTFORWARD(value string) error { + // XRCPTFORWARD must be base64 encoded but can encode empty content + if value == "" { + return errors.New("XRCPTFORWARD value cannot be empty") + } + + // Check if it's valid base64 + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return fmt.Errorf("invalid base64 encoding: %v", err) + } + + // Check size limit (~900 bytes per Dovecot spec) + if len(decoded) > 900 { + return fmt.Errorf("XRCPTFORWARD data too large: %d bytes (max 900)", len(decoded)) + } + + // Validate that it contains tab-separated key=value pairs (empty content is allowed) + decodedStr := string(decoded) + if err := c.validateXRCPTFORWARDContent(decodedStr); err != nil { + return fmt.Errorf("invalid content: %v", err) + } + + return nil +} + +// validateXRCPTFORWARDContent validates the decoded XRCPTFORWARD content +func (c *Conn) validateXRCPTFORWARDContent(content string) error { + // Allow empty content + if content == "" { + return nil + } + + // Content should be tab-separated key=value pairs + // Split by literal tabs first, then unescape individual pairs + pairs := strings.Split(content, "\t") + + for _, pair := range pairs { + if pair == "" { + continue // Allow empty pairs + } + + // Each pair should be key=value + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid key=value pair format: %s", pair) + } + + // Key cannot be empty + if parts[0] == "" { + return errors.New("empty key in key=value pair") + } + } + + return nil +} + +// unescapeXRCPTFORWARD handles Dovecot's escape sequences (\t, \n, \r, \\) +func unescapeXRCPTFORWARD(escaped string) string { + // Order matters: handle \\ first to avoid double-processing + result := strings.ReplaceAll(escaped, "\\\\", "\x00") // temporary placeholder + result = strings.ReplaceAll(result, "\\t", "\t") + result = strings.ReplaceAll(result, "\\n", "\n") + result = strings.ReplaceAll(result, "\\r", "\r") + result = strings.ReplaceAll(result, "\x00", "\\") // restore backslashes + return result +} + +// ParseXRCPTFORWARD parses XRCPTFORWARD data into key=value pairs +// This is a utility function that backends can use to parse the forwarded data +func ParseXRCPTFORWARD(value string) (map[string]string, error) { + if value == "" { + return nil, errors.New("XRCPTFORWARD value cannot be empty") + } + + // Decode base64 + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("invalid base64 encoding: %v", err) + } + + content := string(decoded) + + // Split by literal tabs first (before unescaping) + pairs := strings.Split(content, "\t") + result := make(map[string]string) + + for _, pair := range pairs { + if pair == "" { + continue + } + + // Split into key=value + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid key=value pair format: %s", pair) + } + if parts[0] == "" { + return nil, errors.New("empty key in key=value pair") + } + + key := parts[0] + value := parts[1] + + // Unescape the value (not the key) + result[key] = unescapeXRCPTFORWARD(value) + } + + return result, nil +} + +func (c *Conn) handleXCLIENT(arg string) { + // XCLIENT can only be used before authentication + if c.didAuth { + c.writeResponse(503, EnhancedCode{5, 5, 1}, "XCLIENT not permitted after authentication") + return + } + + // XCLIENT can only be used before MAIL FROM + if c.fromReceived { + c.writeResponse(503, EnhancedCode{5, 5, 1}, "XCLIENT not permitted after MAIL FROM") + return + } + + // Check if XCLIENT is enabled + if !c.server.EnableXCLIENT { + c.writeResponse(502, EnhancedCode{5, 5, 1}, "XCLIENT command not implemented") + return + } + + // Check if connection is from trusted network + if !c.isXCLIENTTrusted() { + c.writeResponse(550, EnhancedCode{5, 7, 1}, "XCLIENT denied") + return + } + + // Parse XCLIENT attributes + attrs, err := c.parseXCLIENTArgs(arg) + if err != nil { + c.writeResponse(501, EnhancedCode{5, 5, 4}, fmt.Sprintf("Invalid XCLIENT syntax: %v", err)) + return + } + + // Validate attributes + if err := c.validateXCLIENTAttrs(attrs); err != nil { + c.writeResponse(501, EnhancedCode{5, 5, 4}, fmt.Sprintf("Invalid XCLIENT attributes: %v", err)) + return + } + + // Initialize xclientData if not already done + if c.xclientData == nil { + c.xclientData = make(map[string]string) + } + + // Store attributes + for name, value := range attrs { + c.xclientData[name] = value + } + + // Call backend XCLIENT handler if implemented + if xclientBackend, ok := c.Session().(XCLIENTBackend); ok { + if err := xclientBackend.XCLIENT(c.Session(), attrs); err != nil { + c.writeError(451, EnhancedCode{4, 0, 0}, err) + return + } + } + + // Per XCLIENT spec, we must reset the session state and issue a new + // greeting. This is similar to what we do for STARTTLS. + if session := c.Session(); session != nil { + session.Logout() + c.setSession(nil) + } + c.helo = "" + c.didAuth = false + c.reset() + + // And send a new greeting. + c.greet() +} + +func (c *Conn) isXCLIENTTrusted() bool { + if len(c.server.XCLIENTTrustedNets) == 0 { + return false + } + + clientIP, _, err := net.SplitHostPort(c.conn.RemoteAddr().String()) + if err != nil { + return false + } + + ip := net.ParseIP(clientIP) + if ip == nil { + return false + } + + for _, network := range c.server.XCLIENTTrustedNets { + if network.Contains(ip) { + return true + } + } + + return false +} + +func (c *Conn) parseXCLIENTArgs(arg string) (map[string]string, error) { + attrs := make(map[string]string) + + if arg == "" { + return attrs, nil + } + + fields := strings.Fields(arg) + for _, field := range fields { + parts := strings.SplitN(field, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid attribute format: %s", field) + } + + name := strings.ToUpper(parts[0]) + value := parts[1] + + attrs[name] = value + } + + return attrs, nil +} + +func (c *Conn) validateXCLIENTAttrs(attrs map[string]string) error { + // List of valid XCLIENT attributes + validAttrs := map[string]bool{ + "ADDR": true, + "PORT": true, + "PROTO": true, + "HELO": true, + "LOGIN": true, + "NAME": true, + } + + for name, value := range attrs { + if !validAttrs[name] { + return fmt.Errorf("unknown attribute: %s", name) + } + + // Validate special values + if value == "[UNAVAILABLE]" || value == "[TEMPUNAVAIL]" { + continue + } + + // Validate specific attributes + switch name { + case "ADDR": + addrValue := value + if strings.HasPrefix(strings.ToLower(addrValue), "ipv6:") { + addrValue = addrValue[5:] + } + if net.ParseIP(addrValue) == nil { + return fmt.Errorf("invalid IP address for ADDR: %s", value) + } + case "PORT": + if port, err := strconv.Atoi(value); err != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid port number for PORT: %s", value) + } + case "PROTO": + validProtos := map[string]bool{"SMTP": true, "ESMTP": true} + if !validProtos[strings.ToUpper(value)] { + return fmt.Errorf("invalid protocol for PROTO: %s", value) + } + case "HELO", "LOGIN", "NAME": + if value == "" { + return fmt.Errorf("empty value for %s", name) + } + } + } + + return nil +} + +// XCLIENTData returns the XCLIENT attributes provided by the client +func (c *Conn) XCLIENTData() map[string]string { + c.locker.Lock() + defer c.locker.Unlock() + + if c.xclientData == nil { + return nil + } + + // Return a copy to prevent modification + result := make(map[string]string) + for k, v := range c.xclientData { + result[k] = v + } + return result +} + func (c *Conn) reset() { c.locker.Lock() defer c.locker.Unlock() diff --git a/example_xclient_test.go b/example_xclient_test.go new file mode 100644 index 00000000..8b25bc5c --- /dev/null +++ b/example_xclient_test.go @@ -0,0 +1,92 @@ +package smtp_test + +import ( + "fmt" + "io" + "log" + + "github.com/emersion/go-smtp" +) + +// Backend that implements XCLIENT support +type XCLIENTBackend struct{} + +func (b *XCLIENTBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &XCLIENTSession{conn: c}, nil +} + +type XCLIENTSession struct { + conn *smtp.Conn +} + +func (s *XCLIENTSession) Mail(from string, opts *smtp.MailOptions) error { + // Access XCLIENT data to get real client information + xclientData := s.conn.XCLIENTData() + if xclientData != nil { + if realAddr, ok := xclientData["ADDR"]; ok && realAddr != "[UNAVAILABLE]" { + log.Printf("Mail from %s via proxy, real client: %s", from, realAddr) + } + if realHelo, ok := xclientData["HELO"]; ok && realHelo != "[UNAVAILABLE]" { + log.Printf("Real HELO: %s", realHelo) + } + } + return nil +} + +func (s *XCLIENTSession) Rcpt(to string, opts *smtp.RcptOptions) error { + return nil +} + +func (s *XCLIENTSession) Data(r io.Reader) error { + return nil +} + +func (s *XCLIENTSession) Reset() {} + +func (s *XCLIENTSession) Logout() error { + return nil +} + +// Implement XCLIENT backend interface +func (s *XCLIENTSession) XCLIENT(session smtp.Session, attrs map[string]string) error { + // Validate and process XCLIENT attributes + for name, value := range attrs { + switch name { + case "ADDR": + if value != "[UNAVAILABLE]" && value != "[TEMPUNAVAIL]" { + log.Printf("Client connected via proxy from real IP: %s", value) + } + case "HELO": + if value != "[UNAVAILABLE]" && value != "[TEMPUNAVAIL]" { + log.Printf("Real client HELO: %s", value) + } + case "LOGIN": + if value != "[UNAVAILABLE]" && value != "[TEMPUNAVAIL]" { + log.Printf("Client authenticated as: %s", value) + } + } + } + return nil +} + +func ExampleServer_xclient() { + be := &XCLIENTBackend{} + + s := smtp.NewServer(be) + s.Addr = ":2525" + s.Domain = "localhost" + s.EnableXCLIENT = true + + // Add trusted networks for XCLIENT + err := s.AddXCLIENTTrustedNetwork("127.0.0.0/8") + if err != nil { + log.Fatal(err) + } + err = s.AddXCLIENTTrustedNetwork("192.168.0.0/16") + if err != nil { + log.Fatal(err) + } + + fmt.Println("XCLIENT server configured") + // Output: XCLIENT server configured +} diff --git a/parse.go b/parse.go index 14d597d0..16a7668c 100644 --- a/parse.go +++ b/parse.go @@ -52,14 +52,11 @@ func parseCmd(line string) (cmd string, arg string, err error) { func parseArgs(s string) (map[string]string, error) { argMap := map[string]string{} for _, arg := range strings.Fields(s) { - m := strings.Split(arg, "=") - switch len(m) { - case 2: - argMap[strings.ToUpper(m[0])] = m[1] - case 1: - argMap[strings.ToUpper(m[0])] = "" - default: - return nil, fmt.Errorf("failed to parse arg string: %q", arg) + key, value, found := strings.Cut(arg, "=") + if found { + argMap[strings.ToUpper(key)] = value + } else { + argMap[strings.ToUpper(key)] = "" } } return argMap, nil diff --git a/rcpt_extensions_integration_test.go b/rcpt_extensions_integration_test.go new file mode 100644 index 00000000..9b4e232a --- /dev/null +++ b/rcpt_extensions_integration_test.go @@ -0,0 +1,399 @@ +package smtp + +import ( + "bufio" + "encoding/base64" + "fmt" + "io" + "net" + "strings" + "testing" +) + +// Helper function to read a line from bufio.Reader and convert to string +func readLine(reader *bufio.Reader) (string, error) { + line, _, err := reader.ReadLine() + return string(line), err +} + +// Test backend that captures RCPT options with extensions +type TestBackendWithExtensions struct { + rcptOptions []*RcptOptions + mailFrom string + dataContent string +} + +func (b *TestBackendWithExtensions) NewSession(c *Conn) (Session, error) { + return &TestSessionWithExtensions{backend: b}, nil +} + +type TestSessionWithExtensions struct { + backend *TestBackendWithExtensions +} + +func (s *TestSessionWithExtensions) Mail(from string, opts *MailOptions) error { + s.backend.mailFrom = from + return nil +} + +func (s *TestSessionWithExtensions) Rcpt(to string, opts *RcptOptions) error { + // Store a copy of the options to verify later + optsCopy := &RcptOptions{ + Notify: opts.Notify, + OriginalRecipientType: opts.OriginalRecipientType, + OriginalRecipient: opts.OriginalRecipient, + RequireRecipientValidSince: opts.RequireRecipientValidSince, + DeliverBy: opts.DeliverBy, + MTPriority: opts.MTPriority, + } + if opts.Extensions != nil { + optsCopy.Extensions = make(map[string]string) + for k, v := range opts.Extensions { + optsCopy.Extensions[k] = v + } + } + s.backend.rcptOptions = append(s.backend.rcptOptions, optsCopy) + return nil +} + +func (s *TestSessionWithExtensions) Data(r io.Reader) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + s.backend.dataContent = string(data) + return nil +} + +func (s *TestSessionWithExtensions) Reset() { + s.backend.rcptOptions = nil + s.backend.mailFrom = "" + s.backend.dataContent = "" +} + +func (s *TestSessionWithExtensions) Logout() error { + return nil +} + +// Integration test for RCPT with custom extensions +func TestServer_RcptWithExtensions(t *testing.T) { + backend := &TestBackendWithExtensions{} + server := NewServer(backend) + server.Domain = "example.com" + server.AllowInsecureAuth = true + server.EnableDSN = true + server.EnableRCPTExtensions = true + + // Create a pipe for testing + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + // Run server handler in background + go func() { + conn := newConn(serverConn, server) + server.handleConn(conn) + }() + + // Client side + client := bufio.NewReader(clientConn) + + // Read greeting + greeting, err := readLine(client) + if err != nil { + t.Fatalf("Failed to read greeting: %v", err) + } + if !strings.HasPrefix(greeting, "220") { + t.Fatalf("Expected 220 greeting, got: %s", greeting) + } + + // Send EHLO (required for extensions) + clientConn.Write([]byte("EHLO client.example.com\r\n")) + // Read EHLO response (multiple lines) + for { + line, _ := readLine(client) + if strings.HasPrefix(line, "250 ") { // Last line starts with "250 " + break + } + } + + // Send MAIL FROM + clientConn.Write([]byte("MAIL FROM:\r\n")) + response, _ := readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("MAIL FROM failed: %s", response) + } + + // Test 1: RCPT with standard parameters only + clientConn.Write([]byte("RCPT TO: NOTIFY=SUCCESS\r\n")) + response, _ = readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("RCPT with standard params failed: %s", response) + } + + // Test 2: RCPT with XRCPTFORWARD extension + xrcptData := base64.StdEncoding.EncodeToString([]byte("user=john\\tsmith\tsession=12345\tip=192.168.1.100")) + rcptCmd := fmt.Sprintf("RCPT TO: XRCPTFORWARD=%s\r\n", xrcptData) + clientConn.Write([]byte(rcptCmd)) + response, _ = readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("RCPT with XRCPTFORWARD failed: %s", response) + } + + // Test 3: RCPT with mixed standard and custom parameters + customCmd := fmt.Sprintf("RCPT TO: NOTIFY=FAILURE XRCPTFORWARD=%s CUSTOM=value\r\n", xrcptData) + clientConn.Write([]byte(customCmd)) + response, _ = readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("RCPT with mixed params failed: %s", response) + } + + // Verify backend received the data correctly BEFORE sending DATA (which triggers reset) + if len(backend.rcptOptions) != 3 { + t.Fatalf("Expected 3 RCPT options, got %d", len(backend.rcptOptions)) + } + + // Test 1 verification: Standard parameters only + opts1 := backend.rcptOptions[0] + if len(opts1.Notify) != 1 || opts1.Notify[0] != DSNNotifySuccess { + t.Error("Standard NOTIFY parameter not handled correctly") + } + if opts1.Extensions != nil { + t.Error("Extensions should be nil for standard-only parameters") + } + + // Test 2 verification: XRCPTFORWARD extension + opts2 := backend.rcptOptions[1] + if opts2.Extensions == nil { + t.Fatal("Extensions should not be nil for XRCPTFORWARD") + } + if opts2.Extensions["XRCPTFORWARD"] != xrcptData { + t.Error("XRCPTFORWARD data not stored correctly") + } + + // Parse the XRCPTFORWARD data + parsedData, err := ParseXRCPTFORWARD(opts2.Extensions["XRCPTFORWARD"]) + if err != nil { + t.Fatalf("Failed to parse XRCPTFORWARD data: %v", err) + } + if parsedData["user"] != "john\tsmith" { + t.Errorf("XRCPTFORWARD user data incorrect: got %q", parsedData["user"]) + } + if parsedData["session"] != "12345" { + t.Errorf("XRCPTFORWARD session data incorrect: got %q", parsedData["session"]) + } + + // Test 3 verification: Mixed parameters + opts3 := backend.rcptOptions[2] + if len(opts3.Notify) != 1 || opts3.Notify[0] != DSNNotifyFailure { + t.Error("Mixed standard parameter NOTIFY not handled correctly") + } + if opts3.Extensions == nil { + t.Fatal("Extensions should not be nil for mixed parameters") + } + if opts3.Extensions["XRCPTFORWARD"] != xrcptData { + t.Error("Mixed XRCPTFORWARD data not stored correctly") + } + if opts3.Extensions["CUSTOM"] != "value" { + t.Error("Mixed custom parameter not stored correctly") + } + + // Send DATA + clientConn.Write([]byte("DATA\r\n")) + response, _ = readLine(client) + if !strings.HasPrefix(response, "354") { + t.Fatalf("DATA failed: %s", response) + } + + // Send message content + clientConn.Write([]byte("Subject: Test\r\n\r\nTest message\r\n.\r\n")) + response, _ = readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("Message send failed: %s", response) + } + + // Send QUIT + clientConn.Write([]byte("QUIT\r\n")) + response, _ = readLine(client) + if !strings.HasPrefix(response, "221") { + t.Fatalf("QUIT failed: %s", response) + } +} + +// Test malformed XRCPTFORWARD handling +func TestServer_RcptWithMalformedXRCPTFORWARD(t *testing.T) { + backend := &TestBackendWithExtensions{} + server := NewServer(backend) + server.Domain = "example.com" + server.EnableDSN = true + server.EnableRCPTExtensions = true + + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + conn := newConn(serverConn, server) + server.handleConn(conn) + }() + + client := bufio.NewReader(clientConn) + + // Read greeting and send EHLO/MAIL + readLine(client) // greeting + clientConn.Write([]byte("EHLO client.example.com\r\n")) + // Read EHLO response (multiple lines) + for { + line, _ := readLine(client) + if strings.HasPrefix(line, "250 ") { // Last line starts with "250 " + break + } + } + clientConn.Write([]byte("MAIL FROM:\r\n")) + readLine(client) + + // Test malformed XRCPTFORWARD (invalid base64) + clientConn.Write([]byte("RCPT TO: XRCPTFORWARD=invalid-base64!\r\n")) + response, _ := readLine(client) + if !strings.HasPrefix(response, "501") { + t.Fatalf("Expected 501 error for invalid base64, got: %s", response) + } + + // Test XRCPTFORWARD with content too large + largeData := make([]byte, 1000) // > 900 bytes + for i := range largeData { + largeData[i] = 'a' + } + largeEncoded := base64.StdEncoding.EncodeToString(largeData) + clientConn.Write([]byte(fmt.Sprintf("RCPT TO: XRCPTFORWARD=%s\r\n", largeEncoded))) + response, _ = readLine(client) + if !strings.HasPrefix(response, "501") { + t.Fatalf("Expected 501 error for too large content, got: %s", response) + } + + // Cleanup + clientConn.Write([]byte("QUIT\r\n")) + readLine(client) +} + +// Test backward compatibility - ensure old code still works +func TestServer_BackwardCompatibilityRcpt(t *testing.T) { + backend := &TestBackendWithExtensions{} + server := NewServer(backend) + server.Domain = "example.com" + server.EnableDSN = true // Enable DSN for testing standard parameters + + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + conn := newConn(serverConn, server) + server.handleConn(conn) + }() + + client := bufio.NewReader(clientConn) + + // Complete SMTP transaction with only standard parameters + readLine(client) // greeting + clientConn.Write([]byte("EHLO client.example.com\r\n")) + + // Read EHLO response (multiple lines) + for { + line, _ := readLine(client) + if strings.HasPrefix(line, "250 ") { // Last line starts with "250 " + break + } + } + + clientConn.Write([]byte("MAIL FROM:\r\n")) + readLine(client) + + // Send RCPT with standard DSN parameters only + clientConn.Write([]byte("RCPT TO: NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;user@example.com\r\n")) + response, _ := readLine(client) + if !strings.HasPrefix(response, "250") { + t.Fatalf("RCPT with DSN params failed: %s", response) + } + + // Verify backward compatibility BEFORE DATA (which triggers reset) + if len(backend.rcptOptions) != 1 { + t.Fatalf("Expected 1 RCPT option, got %d", len(backend.rcptOptions)) + } + + opts := backend.rcptOptions[0] + + // Standard parameters should work as before + if len(opts.Notify) != 2 { + t.Errorf("Expected 2 notify options, got %d", len(opts.Notify)) + } + + if opts.OriginalRecipient != "user@example.com" { + t.Errorf("ORCPT not handled correctly: got %q", opts.OriginalRecipient) + } + + // Extensions should be nil for standard-only parameters + if opts.Extensions != nil { + t.Error("Extensions should be nil when only standard parameters are used") + } + + clientConn.Write([]byte("DATA\r\n")) + readLine(client) + clientConn.Write([]byte("Test message\r\n.\r\n")) + readLine(client) + clientConn.Write([]byte("QUIT\r\n")) + readLine(client) +} + +// Test that unknown RCPT parameters return error 500 when extensions are disabled +func TestServer_RcptExtensionsDisabled(t *testing.T) { + backend := &TestBackendWithExtensions{} + server := NewServer(backend) + server.Domain = "example.com" + server.EnableDSN = true + // Note: EnableRCPTExtensions is false by default + + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + conn := newConn(serverConn, server) + server.handleConn(conn) + }() + + client := bufio.NewReader(clientConn) + + // Read greeting and send EHLO/MAIL + readLine(client) // greeting + clientConn.Write([]byte("EHLO client.example.com\r\n")) + // Read EHLO response (multiple lines) + for { + line, _ := readLine(client) + if strings.HasPrefix(line, "250 ") { // Last line starts with "250 " + break + } + } + clientConn.Write([]byte("MAIL FROM:\r\n")) + readLine(client) + + // Test that unknown RCPT parameter returns error 500 + clientConn.Write([]byte("RCPT TO: UNKNOWNPARAM=value\r\n")) + response, _ := readLine(client) + if !strings.HasPrefix(response, "500") { + t.Fatalf("Expected 500 error for unknown parameter when extensions disabled, got: %s", response) + } + + // Test that XRCPTFORWARD also returns error 500 when extensions disabled + xrcptData := base64.StdEncoding.EncodeToString([]byte("user=john\tsession=12345")) + rcptCmd := fmt.Sprintf("RCPT TO: XRCPTFORWARD=%s\r\n", xrcptData) + clientConn.Write([]byte(rcptCmd)) + response, _ = readLine(client) + if !strings.HasPrefix(response, "500") { + t.Fatalf("Expected 500 error for XRCPTFORWARD when extensions disabled, got: %s", response) + } + + // Cleanup + clientConn.Write([]byte("QUIT\r\n")) + readLine(client) +} diff --git a/rcpt_extensions_test.go b/rcpt_extensions_test.go new file mode 100644 index 00000000..d18c29a9 --- /dev/null +++ b/rcpt_extensions_test.go @@ -0,0 +1,292 @@ +package smtp + +import ( + "encoding/base64" + "testing" +) + +func TestRcptOptions_Extensions(t *testing.T) { + // Test that RcptOptions can hold custom extensions + opts := &RcptOptions{ + Extensions: map[string]string{ + "XRCPTFORWARD": "dXNlcj1qb2huCXNlc3Npb249MTIzNDU=", + "CUSTOM": "value", + }, + } + + if opts.Extensions["XRCPTFORWARD"] != "dXNlcj1qb2huCXNlc3Npb249MTIzNDU=" { + t.Error("XRCPTFORWARD not stored correctly") + } + + if opts.Extensions["CUSTOM"] != "value" { + t.Error("Custom extension not stored correctly") + } +} + +func TestParseXRCPTFORWARD(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + hasError bool + }{ + { + name: "valid simple data", + input: base64.StdEncoding.EncodeToString([]byte("user=john\tsession=12345")), + expected: map[string]string{ + "user": "john", + "session": "12345", + }, + }, + { + name: "with escaped characters in values", + input: base64.StdEncoding.EncodeToString([]byte("name=john\\tsmith\tpath=/var\\nmailbox")), + expected: map[string]string{ + "name": "john\tsmith", + "path": "/var\nmailbox", + }, + }, + { + name: "empty value", + input: base64.StdEncoding.EncodeToString([]byte("user=\tactive=true")), + expected: map[string]string{ + "user": "", + "active": "true", + }, + }, + { + name: "single key-value pair", + input: base64.StdEncoding.EncodeToString([]byte("forwarded=true")), + expected: map[string]string{ + "forwarded": "true", + }, + }, + { + name: "empty input", + input: "", + hasError: true, + }, + { + name: "invalid base64", + input: "invalid-base64!", + hasError: true, + }, + { + name: "empty pairs ignored", + input: base64.StdEncoding.EncodeToString([]byte("user=john\t\tactive=true")), + expected: map[string]string{ + "user": "john", + "active": "true", + }, + }, + { + name: "invalid key=value format", + input: base64.StdEncoding.EncodeToString([]byte("invalidformat\tuser=john")), + hasError: true, + }, + { + name: "empty key", + input: base64.StdEncoding.EncodeToString([]byte("=value\tuser=john")), + hasError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseXRCPTFORWARD(tt.input) + + if tt.hasError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(result) != len(tt.expected) { + t.Errorf("result length %d, expected %d", len(result), len(tt.expected)) + } + + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("result[%s] = %q, expected %q", k, result[k], v) + } + } + }) + } +} + +func TestConn_validateXRCPTFORWARD(t *testing.T) { + conn := &Conn{} + + tests := []struct { + name string + input string + hasError bool + }{ + { + name: "valid XRCPTFORWARD", + input: base64.StdEncoding.EncodeToString([]byte("user=john\tsession=12345")), + }, + { + name: "with escaped characters", + input: base64.StdEncoding.EncodeToString([]byte("path=/var\\tmailbox\nowner=user")), + }, + { + name: "empty value", + input: "", + hasError: true, + }, + { + name: "invalid base64", + input: "not-base64!", + hasError: true, + }, + { + name: "too large content", + input: base64.StdEncoding.EncodeToString(make([]byte, 1000)), // > 900 bytes + hasError: true, + }, + { + name: "invalid key=value format", + input: base64.StdEncoding.EncodeToString([]byte("invalidformat\tuser=john")), + hasError: true, + }, + { + name: "empty key", + input: base64.StdEncoding.EncodeToString([]byte("=value\tuser=john")), + hasError: true, + }, + { + name: "empty base64 string not allowed", + input: base64.StdEncoding.EncodeToString([]byte("")), // This produces "" + hasError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := conn.validateXRCPTFORWARD(tt.input) + + if tt.hasError { + if err == nil { + t.Error("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestUnescapeXRCPTFORWARD(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + input: "user=john\\tsmith", + expected: "user=john\tsmith", + }, + { + input: "path=/var\\nmailbox", + expected: "path=/var\nmailbox", + }, + { + input: "data=line1\\r\\nline2", + expected: "data=line1\r\nline2", + }, + { + input: "escaped=\\\\backslash", + expected: "escaped=\\backslash", + }, + { + input: "mixed=\\t\\n\\r\\\\test", + expected: "mixed=\t\n\r\\test", + }, + { + input: "noescapes=normaltext", + expected: "noescapes=normaltext", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := unescapeXRCPTFORWARD(tt.input) + if result != tt.expected { + t.Errorf("unescapeXRCPTFORWARD(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestRcptOptions_BackwardCompatibility(t *testing.T) { + // Test that existing code still works with nil Extensions + opts := &RcptOptions{ + Notify: []DSNNotify{DSNNotifySuccess}, + } + + // Extensions should be nil by default + if opts.Extensions != nil { + t.Error("Extensions should be nil by default") + } + + // Test that we can safely read from nil Extensions + if opts.Extensions["NONEXISTENT"] != "" { + t.Error("Reading from nil Extensions should return empty string") + } +} + +// Test integration with actual RCPT command parsing +func TestRcptExtensionsParsing(t *testing.T) { + // This would be an integration test showing how the RCPT command + // with extensions gets parsed. Since it requires a server setup, + // we'll create a more focused unit test for the argument parsing logic. + + // Test that parseArgs works with custom parameters + // This simulates what happens in handleRcpt when custom parameters are present + + // Mock the parseArgs function behavior (this would need to be extracted to test properly) + // For now, we verify the structure is correct for the Extensions field + + opts := &RcptOptions{} + + // Simulate what the RCPT handler would do with custom parameters + if opts.Extensions == nil { + opts.Extensions = make(map[string]string) + } + + // Add XRCPTFORWARD + opts.Extensions["XRCPTFORWARD"] = base64.StdEncoding.EncodeToString([]byte("user=john\tsession=12345")) + + // Add other custom parameter + opts.Extensions["CUSTOM"] = "value" + + // Verify standard parameters still work + opts.Notify = []DSNNotify{DSNNotifySuccess} + + // Check that both standard and custom parameters coexist + if len(opts.Extensions) != 2 { + t.Errorf("Expected 2 extensions, got %d", len(opts.Extensions)) + } + + if len(opts.Notify) != 1 { + t.Errorf("Expected 1 notify option, got %d", len(opts.Notify)) + } + + // Test parsing the XRCPTFORWARD data + data, err := ParseXRCPTFORWARD(opts.Extensions["XRCPTFORWARD"]) + if err != nil { + t.Errorf("Failed to parse XRCPTFORWARD: %v", err) + } + + if data["user"] != "john" || data["session"] != "12345" { + t.Error("XRCPTFORWARD data not parsed correctly") + } +} diff --git a/server.go b/server.go index e0e0acd0..ddb585d3 100644 --- a/server.go +++ b/server.go @@ -79,6 +79,18 @@ type Server struct { // Default value of NONE to advertise no specific profile. MtPriorityProfile PriorityProfile + // Allow custom RCPT TO parameters (extensions like XRCPTFORWARD). + // When disabled, unknown RCPT parameters will return error 500 like before. + // Should only be used if backend supports custom extensions. + EnableRCPTExtensions bool + + // Advertise XCLIENT (Postfix extension) capability. + // Should only be used if backend supports it and proper trusted networks are configured. + EnableXCLIENT bool + // Trusted networks for XCLIENT command. Only connections from these networks + // are allowed to use XCLIENT. If empty, XCLIENT is effectively disabled. + XCLIENTTrustedNets []*net.IPNet + // The server backend. Backend Backend @@ -103,6 +115,17 @@ func NewServer(be Backend) *Server { } } +// AddXCLIENTTrustedNetwork adds a trusted network for XCLIENT command. +// The network should be in CIDR notation (e.g., "192.168.1.0/24", "::1/128"). +func (s *Server) AddXCLIENTTrustedNetwork(network string) error { + _, ipnet, err := net.ParseCIDR(network) + if err != nil { + return err + } + s.XCLIENTTrustedNets = append(s.XCLIENTTrustedNets, ipnet) + return nil +} + // Serve accepts incoming connections on the Listener l. func (s *Server) Serve(l net.Listener) error { s.locker.Lock() diff --git a/smtp.go b/smtp.go index 405f5c53..f5acfba1 100644 --- a/smtp.go +++ b/smtp.go @@ -130,4 +130,9 @@ type RcptOptions struct { // Value of MT-PRIORITY= or nil if unset. MTPriority *int + + // Extensions contains custom RCPT parameters not defined in standard SMTP. + // This includes extensions like XRCPTFORWARD and other server-specific parameters. + // The map is nil if no custom parameters are present. + Extensions map[string]string } diff --git a/xclient_test.go b/xclient_test.go new file mode 100644 index 00000000..3a87a108 --- /dev/null +++ b/xclient_test.go @@ -0,0 +1,247 @@ +package smtp + +import ( + "net" + "testing" + "time" +) + +func TestXCLIENTTrustedNetworks(t *testing.T) { + // Test trusted network checking + _, network, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatal(err) + } + + server := &Server{ + EnableXCLIENT: true, + XCLIENTTrustedNets: []*net.IPNet{network}, + } + + // Mock connection from trusted IP + trustedAddr, _ := net.ResolveTCPAddr("tcp", "192.168.1.10:12345") + untrustedAddr, _ := net.ResolveTCPAddr("tcp", "10.0.0.1:12345") + + tests := []struct { + name string + addr net.Addr + expected bool + }{ + {"trusted IP", trustedAddr, true}, + {"untrusted IP", untrustedAddr, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := &mockConn{addr: tt.addr} + c := &Conn{ + conn: conn, + server: server, + } + + result := c.isXCLIENTTrusted() + if result != tt.expected { + t.Errorf("isXCLIENTTrusted() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestParseXCLIENTArgs(t *testing.T) { + conn := &Conn{} + + tests := []struct { + name string + arg string + expected map[string]string + hasError bool + }{ + { + name: "valid attributes", + arg: "ADDR=192.168.1.1 PORT=25 PROTO=ESMTP", + expected: map[string]string{ + "ADDR": "192.168.1.1", + "PORT": "25", + "PROTO": "ESMTP", + }, + }, + { + name: "special values", + arg: "ADDR=[UNAVAILABLE] LOGIN=[TEMPUNAVAIL]", + expected: map[string]string{ + "ADDR": "[UNAVAILABLE]", + "LOGIN": "[TEMPUNAVAIL]", + }, + }, + { + name: "invalid format", + arg: "INVALID_FORMAT", + hasError: true, + }, + { + name: "empty args", + arg: "", + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := conn.parseXCLIENTArgs(tt.arg) + if tt.hasError { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(result) != len(tt.expected) { + t.Errorf("result length %d, expected %d", len(result), len(tt.expected)) + } + + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("result[%s] = %s, expected %s", k, result[k], v) + } + } + }) + } +} + +func TestValidateXCLIENTAttrs(t *testing.T) { + conn := &Conn{} + + tests := []struct { + name string + attrs map[string]string + hasError bool + }{ + { + name: "valid attributes", + attrs: map[string]string{ + "ADDR": "192.168.1.1", + "PORT": "25", + "PROTO": "ESMTP", + "HELO": "example.com", + }, + }, + { + name: "valid IPv6 address with prefix", + attrs: map[string]string{ + "ADDR": "ipv6:2001:db8::1", + }, + }, + { + name: "special values", + attrs: map[string]string{ + "ADDR": "[UNAVAILABLE]", + "LOGIN": "[TEMPUNAVAIL]", + }, + }, + { + name: "invalid attribute name", + attrs: map[string]string{ + "INVALID": "value", + }, + hasError: true, + }, + { + name: "invalid IP address", + attrs: map[string]string{ + "ADDR": "invalid-ip", + }, + hasError: true, + }, + { + name: "invalid empty ADDR", + attrs: map[string]string{ + "ADDR": "", + }, + hasError: true, + }, + { + name: "invalid port", + attrs: map[string]string{ + "PORT": "99999", + }, + hasError: true, + }, + { + name: "invalid empty PORT", + attrs: map[string]string{ + "PORT": "", + }, + hasError: true, + }, + { + name: "invalid protocol", + attrs: map[string]string{ + "PROTO": "HTTP", + }, + hasError: true, + }, + { + name: "empty HELO", + attrs: map[string]string{ + "HELO": "", + }, + hasError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := conn.validateXCLIENTAttrs(tt.attrs) + if tt.hasError { + if err == nil { + t.Error("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +// Mock connection for testing +type mockConn struct { + addr net.Addr +} + +func (m *mockConn) Read(b []byte) (n int, err error) { + return 0, nil +} + +func (m *mockConn) Write(b []byte) (n int, err error) { + return len(b), nil +} + +func (m *mockConn) Close() error { + return nil +} + +func (m *mockConn) LocalAddr() net.Addr { + return m.addr +} + +func (m *mockConn) RemoteAddr() net.Addr { + return m.addr +} + +func (m *mockConn) SetDeadline(t time.Time) error { + return nil +} + +func (m *mockConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (m *mockConn) SetWriteDeadline(t time.Time) error { + return nil +}