Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
47 changes: 47 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
190 changes: 190 additions & 0 deletions client_xclient_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading