client_test.go raw

   1  package smtp
   2  
   3  import (
   4  	"context"
   5  	"net"
   6  	"strings"
   7  	"testing"
   8  	"time"
   9  
  10  	gosmtp "github.com/emersion/go-smtp"
  11  )
  12  
  13  func TestNewClient(t *testing.T) {
  14  	c := NewClient(ClientConfig{FromDomain: "example.com"})
  15  	if c == nil {
  16  		t.Fatal("expected non-nil Client")
  17  	}
  18  	if c.resolver == nil {
  19  		t.Error("expected non-nil resolver")
  20  	}
  21  	if c.dialer == nil {
  22  		t.Error("expected non-nil dialer")
  23  	}
  24  }
  25  
  26  func TestGroupByDomain(t *testing.T) {
  27  	result := groupByDomain([]string{
  28  		"alice@example.com",
  29  		"bob@example.com",
  30  		"charlie@other.com",
  31  		"invalid-no-at",
  32  	})
  33  	if len(result) != 2 {
  34  		t.Fatalf("expected 2 domains, got %d", len(result))
  35  	}
  36  	if len(result["example.com"]) != 2 {
  37  		t.Errorf("example.com count = %d, want 2", len(result["example.com"]))
  38  	}
  39  	if len(result["other.com"]) != 1 {
  40  		t.Errorf("other.com count = %d, want 1", len(result["other.com"]))
  41  	}
  42  }
  43  
  44  func TestGroupByDomain_CaseInsensitive(t *testing.T) {
  45  	result := groupByDomain([]string{
  46  		"alice@Example.COM",
  47  		"bob@EXAMPLE.com",
  48  	})
  49  	if len(result) != 1 {
  50  		t.Errorf("expected 1 domain, got %d", len(result))
  51  	}
  52  }
  53  
  54  func TestGroupByDomain_Empty(t *testing.T) {
  55  	result := groupByDomain(nil)
  56  	if len(result) != 0 {
  57  		t.Errorf("expected 0 domains, got %d", len(result))
  58  	}
  59  }
  60  
  61  func TestBuildMessage(t *testing.T) {
  62  	c := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
  63  	email := &OutboundEmail{
  64  		From:    "npub1abc@bridge.example.com",
  65  		To:      []string{"alice@example.com"},
  66  		Subject: "Test Subject",
  67  		Body:    "Hello, world!",
  68  	}
  69  	msg, err := c.buildMessage(email)
  70  	if err != nil {
  71  		t.Fatalf("unexpected error: %v", err)
  72  	}
  73  	s := string(msg)
  74  	if !strings.Contains(s, "From: npub1abc@bridge.example.com") {
  75  		t.Error("missing From header")
  76  	}
  77  	if !strings.Contains(s, "To: alice@example.com") {
  78  		t.Error("missing To header")
  79  	}
  80  	if !strings.Contains(s, "Subject: Test Subject") {
  81  		t.Error("missing Subject header")
  82  	}
  83  	if !strings.Contains(s, "Hello, world!") {
  84  		t.Error("missing body")
  85  	}
  86  	if !strings.Contains(s, "Message-Id:") {
  87  		t.Error("missing Message-Id")
  88  	}
  89  	if !strings.Contains(s, "Mime-Version: 1.0") {
  90  		t.Error("missing Mime-Version header")
  91  	}
  92  }
  93  
  94  func TestBuildMessage_WithCC(t *testing.T) {
  95  	c := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
  96  	email := &OutboundEmail{
  97  		From:    "from@bridge.example.com",
  98  		To:      []string{"alice@example.com"},
  99  		Cc:      []string{"bob@example.com"},
 100  		Subject: "CC Test",
 101  		Body:    "Body",
 102  	}
 103  	msg, err := c.buildMessage(email)
 104  	if err != nil {
 105  		t.Fatalf("unexpected error: %v", err)
 106  	}
 107  	if !strings.Contains(string(msg), "Cc: bob@example.com") {
 108  		t.Error("missing Cc header")
 109  	}
 110  }
 111  
 112  func TestBuildMessage_BccNotInHeaders(t *testing.T) {
 113  	c := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 114  	email := &OutboundEmail{
 115  		From:    "from@bridge.example.com",
 116  		To:      []string{"alice@example.com"},
 117  		Bcc:     []string{"secret@example.com"},
 118  		Subject: "BCC Test",
 119  		Body:    "Body",
 120  	}
 121  	msg, err := c.buildMessage(email)
 122  	if err != nil {
 123  		t.Fatalf("unexpected error: %v", err)
 124  	}
 125  	if strings.Contains(string(msg), "secret@example.com") {
 126  		t.Error("BCC address should NOT appear in headers")
 127  	}
 128  }
 129  
 130  func TestClient_SendViaLocalServer(t *testing.T) {
 131  	// Start a local SMTP server
 132  	var received *InboundEmail
 133  	server := NewServer(ServerConfig{
 134  		Domain:     "test.example.com",
 135  		ListenAddr: "127.0.0.1:0",
 136  	}, func(email *InboundEmail) error {
 137  		received = email
 138  		return nil
 139  	})
 140  
 141  	if err := server.Start(); err != nil {
 142  		t.Fatalf("start server: %v", err)
 143  	}
 144  	defer server.Stop(context.Background())
 145  
 146  	addr := server.Addr().String()
 147  	_, port, _ := net.SplitHostPort(addr)
 148  
 149  	// Create client with mock resolver pointing to local server
 150  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 151  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 152  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 153  	})
 154  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 155  		// Override to connect to our local port
 156  		return gosmtpDial("127.0.0.1:" + port)
 157  	})
 158  
 159  	email := &OutboundEmail{
 160  		From:    "sender@bridge.example.com",
 161  		To:      []string{"recipient@test.example.com"},
 162  		Subject: "Integration Test",
 163  		Body:    "This is a test email.",
 164  	}
 165  
 166  	err := client.Send(email)
 167  	if err != nil {
 168  		t.Fatalf("Send failed: %v", err)
 169  	}
 170  
 171  	// Wait briefly for async processing
 172  	time.Sleep(100 * time.Millisecond)
 173  
 174  	if received == nil {
 175  		t.Fatal("expected server to receive email")
 176  	}
 177  	if received.From != "sender@bridge.example.com" {
 178  		t.Errorf("From = %q, want sender@bridge.example.com", received.From)
 179  	}
 180  }
 181  
 182  func TestClient_SendWithDKIM(t *testing.T) {
 183  	var received *InboundEmail
 184  	server := NewServer(ServerConfig{
 185  		Domain:     "test.example.com",
 186  		ListenAddr: "127.0.0.1:0",
 187  	}, func(email *InboundEmail) error {
 188  		received = email
 189  		return nil
 190  	})
 191  
 192  	if err := server.Start(); err != nil {
 193  		t.Fatalf("start server: %v", err)
 194  	}
 195  	defer server.Stop(context.Background())
 196  
 197  	addr := server.Addr().String()
 198  	_, port, _ := net.SplitHostPort(addr)
 199  
 200  	// Generate a DKIM key for signing
 201  	keyPEM, _, err := GenerateDKIMKeyPair()
 202  	if err != nil {
 203  		t.Fatalf("generate DKIM: %v", err)
 204  	}
 205  	signer, err := NewDKIMSignerFromPEM("bridge.example.com", "test", keyPEM)
 206  	if err != nil {
 207  		t.Fatalf("create signer: %v", err)
 208  	}
 209  
 210  	client := NewClient(ClientConfig{
 211  		FromDomain: "bridge.example.com",
 212  		DKIMSigner: signer,
 213  	})
 214  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 215  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 216  	})
 217  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 218  		return gosmtpDial("127.0.0.1:" + port)
 219  	})
 220  
 221  	err = client.Send(&OutboundEmail{
 222  		From:    "sender@bridge.example.com",
 223  		To:      []string{"recipient@test.example.com"},
 224  		Subject: "DKIM Test",
 225  		Body:    "Signed message.",
 226  	})
 227  	if err != nil {
 228  		t.Fatalf("Send failed: %v", err)
 229  	}
 230  
 231  	time.Sleep(100 * time.Millisecond)
 232  
 233  	if received == nil {
 234  		t.Fatal("expected server to receive email")
 235  	}
 236  	if !strings.Contains(string(received.RawMessage), "DKIM-Signature:") {
 237  		t.Error("expected DKIM-Signature header in received message")
 238  	}
 239  }
 240  
 241  func TestClient_MXLookupFails(t *testing.T) {
 242  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 243  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 244  		return nil, net.ErrClosed
 245  	})
 246  
 247  	err := client.Send(&OutboundEmail{
 248  		From: "sender@bridge.example.com",
 249  		To:   []string{"alice@example.com"},
 250  		Body: "Test",
 251  	})
 252  	if err == nil {
 253  		t.Fatal("expected error from MX lookup failure")
 254  	}
 255  }
 256  
 257  func TestClient_AllMXFail(t *testing.T) {
 258  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 259  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 260  		return []*net.MX{
 261  			{Host: "192.0.2.1", Pref: 10}, // unreachable
 262  		}, nil
 263  	})
 264  	client.SetDialer(func(addr string) (*gosmtp.Client, error) {
 265  		return nil, net.ErrClosed
 266  	})
 267  
 268  	err := client.Send(&OutboundEmail{
 269  		From: "sender@bridge.example.com",
 270  		To:   []string{"alice@example.com"},
 271  		Body: "Test",
 272  	})
 273  	if err == nil {
 274  		t.Fatal("expected error when all MX fail")
 275  	}
 276  }
 277  
 278  func TestClient_EmptyMXFallback(t *testing.T) {
 279  	// Empty MX list should fall back to A record (the domain itself)
 280  	var dialedAddr string
 281  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 282  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 283  		return nil, nil // empty, no error
 284  	})
 285  	client.SetDialer(func(addr string) (*gosmtp.Client, error) {
 286  		dialedAddr = addr
 287  		return nil, net.ErrClosed
 288  	})
 289  
 290  	client.Send(&OutboundEmail{
 291  		From: "sender@bridge.example.com",
 292  		To:   []string{"alice@example.com"},
 293  		Body: "Test",
 294  	})
 295  	// Should try to connect to example.com:25
 296  	if !strings.Contains(dialedAddr, "example.com") {
 297  		t.Errorf("dialedAddr = %q, want example.com:25", dialedAddr)
 298  	}
 299  }
 300  
 301  func TestClient_MultipleDomains(t *testing.T) {
 302  	var received []*InboundEmail
 303  	server := NewServer(ServerConfig{
 304  		Domain:     "test.example.com",
 305  		ListenAddr: "127.0.0.1:0",
 306  	}, func(email *InboundEmail) error {
 307  		received = append(received, email)
 308  		return nil
 309  	})
 310  
 311  	if err := server.Start(); err != nil {
 312  		t.Fatalf("start server: %v", err)
 313  	}
 314  	defer server.Stop(context.Background())
 315  
 316  	addr := server.Addr().String()
 317  	_, port, _ := net.SplitHostPort(addr)
 318  
 319  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 320  	// Route all domains to our local server
 321  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 322  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 323  	})
 324  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 325  		return gosmtpDial("127.0.0.1:" + port)
 326  	})
 327  
 328  	err := client.Send(&OutboundEmail{
 329  		From: "sender@bridge.example.com",
 330  		To:   []string{"alice@test.example.com", "bob@test.example.com"},
 331  		Body: "Multi-recipient",
 332  	})
 333  	if err != nil {
 334  		t.Fatalf("Send failed: %v", err)
 335  	}
 336  }
 337  
 338  func TestClient_SendWithDKIMSignFailure(t *testing.T) {
 339  	// DKIM signer that always fails — should fall back to sending unsigned
 340  	var received *InboundEmail
 341  	server := NewServer(ServerConfig{
 342  		Domain:     "test.example.com",
 343  		ListenAddr: "127.0.0.1:0",
 344  	}, func(email *InboundEmail) error {
 345  		received = email
 346  		return nil
 347  	})
 348  
 349  	if err := server.Start(); err != nil {
 350  		t.Fatalf("start server: %v", err)
 351  	}
 352  	defer server.Stop(context.Background())
 353  
 354  	addr := server.Addr().String()
 355  	_, port, _ := net.SplitHostPort(addr)
 356  
 357  	// Create a DKIM signer with a tiny invalid key that will fail to sign
 358  	badSigner := &DKIMSigner{
 359  		domain:   "bridge.example.com",
 360  		selector: "test",
 361  		key:      nil, // nil key will cause sign to fail
 362  	}
 363  
 364  	client := NewClient(ClientConfig{
 365  		FromDomain: "bridge.example.com",
 366  		DKIMSigner: badSigner,
 367  	})
 368  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 369  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 370  	})
 371  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 372  		return gosmtpDial("127.0.0.1:" + port)
 373  	})
 374  
 375  	err := client.Send(&OutboundEmail{
 376  		From:    "sender@bridge.example.com",
 377  		To:      []string{"recipient@test.example.com"},
 378  		Subject: "DKIM Fail Test",
 379  		Body:    "Should send unsigned.",
 380  	})
 381  	if err != nil {
 382  		t.Fatalf("Send should succeed even with DKIM failure: %v", err)
 383  	}
 384  
 385  	time.Sleep(100 * time.Millisecond)
 386  
 387  	if received == nil {
 388  		t.Fatal("expected server to receive email (unsigned)")
 389  	}
 390  	// Should NOT have DKIM-Signature since signing failed
 391  	if strings.Contains(string(received.RawMessage), "DKIM-Signature:") {
 392  		t.Error("should NOT have DKIM-Signature when signing fails")
 393  	}
 394  }
 395  
 396  func TestClient_SendWithBcc(t *testing.T) {
 397  	var received []*InboundEmail
 398  	server := NewServer(ServerConfig{
 399  		Domain:     "test.example.com",
 400  		ListenAddr: "127.0.0.1:0",
 401  	}, func(email *InboundEmail) error {
 402  		received = append(received, email)
 403  		return nil
 404  	})
 405  
 406  	if err := server.Start(); err != nil {
 407  		t.Fatalf("start server: %v", err)
 408  	}
 409  	defer server.Stop(context.Background())
 410  
 411  	addr := server.Addr().String()
 412  	_, port, _ := net.SplitHostPort(addr)
 413  
 414  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 415  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 416  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 417  	})
 418  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 419  		return gosmtpDial("127.0.0.1:" + port)
 420  	})
 421  
 422  	err := client.Send(&OutboundEmail{
 423  		From:    "sender@bridge.example.com",
 424  		To:      []string{"alice@test.example.com"},
 425  		Cc:      []string{"bob@test.example.com"},
 426  		Bcc:     []string{"secret@test.example.com"},
 427  		Subject: "BCC Test",
 428  		Body:    "With BCC recipient.",
 429  	})
 430  	if err != nil {
 431  		t.Fatalf("Send failed: %v", err)
 432  	}
 433  
 434  	time.Sleep(100 * time.Millisecond)
 435  
 436  	if len(received) == 0 {
 437  		t.Fatal("expected server to receive email")
 438  	}
 439  	// BCC address should NOT appear in the message headers
 440  	msg := string(received[0].RawMessage)
 441  	if strings.Contains(msg, "secret@test.example.com") {
 442  		t.Error("BCC address should NOT appear in message headers")
 443  	}
 444  }
 445  
 446  func TestClient_SendMultipleDomains_PartialFailure(t *testing.T) {
 447  	// One domain succeeds, another fails
 448  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 449  	resolveCount := 0
 450  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 451  		resolveCount++
 452  		if domain == "fail.com" {
 453  			return nil, net.ErrClosed
 454  		}
 455  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 456  	})
 457  	client.SetDialer(func(addr string) (*gosmtp.Client, error) {
 458  		return nil, net.ErrClosed
 459  	})
 460  
 461  	err := client.Send(&OutboundEmail{
 462  		From: "sender@bridge.example.com",
 463  		To:   []string{"alice@fail.com", "bob@other.com"},
 464  		Body: "Test",
 465  	})
 466  	// Should return an error (the last error)
 467  	if err == nil {
 468  		t.Fatal("expected error from partial delivery failure")
 469  	}
 470  }
 471  
 472  func TestBuildMessage_WithInReplyTo(t *testing.T) {
 473  	c := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 474  	email := &OutboundEmail{
 475  		From:    "from@bridge.example.com",
 476  		To:      []string{"alice@example.com"},
 477  		Cc:      []string{"bob@example.com", "carol@example.com"},
 478  		Bcc:     []string{"secret@example.com"},
 479  		Subject: "Reply Test",
 480  		Body:    "Reply body",
 481  	}
 482  	msg, err := c.buildMessage(email)
 483  	if err != nil {
 484  		t.Fatalf("unexpected error: %v", err)
 485  	}
 486  	s := string(msg)
 487  	// Cc should be in headers
 488  	if !strings.Contains(s, "bob@example.com") {
 489  		t.Error("missing Cc addresses in header")
 490  	}
 491  	// Bcc should NOT be in headers
 492  	if strings.Contains(s, "secret@example.com") {
 493  		t.Error("Bcc address should NOT appear in headers")
 494  	}
 495  	// Date header
 496  	if !strings.Contains(s, "Date:") {
 497  		t.Error("missing Date header")
 498  	}
 499  }
 500  
 501  func TestClient_SendNoDomains(t *testing.T) {
 502  	c := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 503  	// Send to addresses with no @ sign — they're all skipped
 504  	err := c.Send(&OutboundEmail{
 505  		From: "sender@bridge.example.com",
 506  		To:   []string{"invalid-no-at"},
 507  		Body: "Test",
 508  	})
 509  	// groupByDomain returns empty map → no delivery attempts → no error
 510  	if err != nil {
 511  		t.Errorf("expected no error for empty domain map, got: %v", err)
 512  	}
 513  }
 514  
 515  func TestClient_DeliverDirect_HelloFails(t *testing.T) {
 516  	// Start a server that accepts connections but then we can test Hello failure
 517  	// by using a dialer that connects to a server that rejects EHLO
 518  	var received *InboundEmail
 519  	server := NewServer(ServerConfig{
 520  		Domain:     "test.example.com",
 521  		ListenAddr: "127.0.0.1:0",
 522  	}, func(email *InboundEmail) error {
 523  		received = email
 524  		return nil
 525  	})
 526  
 527  	if err := server.Start(); err != nil {
 528  		t.Fatalf("start server: %v", err)
 529  	}
 530  	defer server.Stop(context.Background())
 531  
 532  	addr := server.Addr().String()
 533  	_, port, _ := net.SplitHostPort(addr)
 534  
 535  	// Create client with an empty FromDomain that has a very long invalid hostname
 536  	// to trigger Hello failure on some SMTP implementations
 537  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 538  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 539  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 540  	})
 541  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 542  		return gosmtp.Dial("127.0.0.1:" + port)
 543  	})
 544  
 545  	// This should succeed since our server accepts connections
 546  	err := client.Send(&OutboundEmail{
 547  		From:    "sender@bridge.example.com",
 548  		To:      []string{"alice@test.example.com"},
 549  		Subject: "Hello Test",
 550  		Body:    "Body",
 551  	})
 552  	// Should succeed with our test server
 553  	if err != nil {
 554  		t.Logf("deliverDirect error (may be expected): %v", err)
 555  	}
 556  	_ = received
 557  }
 558  
 559  func TestClient_DeliverDirect_RcptFails(t *testing.T) {
 560  	// Start a server that rejects specific recipients
 561  	server := NewServer(ServerConfig{
 562  		Domain:     "other.example.com", // different domain
 563  		ListenAddr: "127.0.0.1:0",
 564  	}, func(email *InboundEmail) error {
 565  		return nil
 566  	})
 567  
 568  	if err := server.Start(); err != nil {
 569  		t.Fatalf("start server: %v", err)
 570  	}
 571  	defer server.Stop(context.Background())
 572  
 573  	addr := server.Addr().String()
 574  	_, port, _ := net.SplitHostPort(addr)
 575  
 576  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 577  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 578  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 579  	})
 580  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 581  		return gosmtpDial("127.0.0.1:" + port)
 582  	})
 583  
 584  	// The server domain is "other.example.com" so it will reject recipients
 585  	// for "wrong.example.com" domain
 586  	err := client.Send(&OutboundEmail{
 587  		From: "sender@bridge.example.com",
 588  		To:   []string{"alice@wrong.example.com"},
 589  		Body: "Test",
 590  	})
 591  	if err == nil {
 592  		t.Fatal("expected error from RCPT rejection")
 593  	}
 594  }
 595  
 596  func TestClient_DeliverDirect_HelloFails_Rejection(t *testing.T) {
 597  	// Raw TCP server that rejects EHLO/HELO
 598  	ln, err := net.Listen("tcp", "127.0.0.1:0")
 599  	if err != nil {
 600  		t.Fatalf("listen: %v", err)
 601  	}
 602  	defer ln.Close()
 603  
 604  	go func() {
 605  		conn, err := ln.Accept()
 606  		if err != nil {
 607  			return
 608  		}
 609  		defer conn.Close()
 610  		conn.Write([]byte("220 test ESMTP\r\n"))
 611  		buf := make([]byte, 1024)
 612  		for {
 613  			n, err := conn.Read(buf)
 614  			if err != nil {
 615  				return
 616  			}
 617  			cmd := strings.ToUpper(strings.TrimSpace(string(buf[:n])))
 618  			switch {
 619  			case strings.HasPrefix(cmd, "EHLO"):
 620  				conn.Write([]byte("550 EHLO rejected\r\n"))
 621  			case strings.HasPrefix(cmd, "HELO"):
 622  				conn.Write([]byte("550 HELO rejected\r\n"))
 623  			case strings.HasPrefix(cmd, "QUIT"):
 624  				conn.Write([]byte("221 Bye\r\n"))
 625  				return
 626  			default:
 627  				conn.Write([]byte("500 Unknown\r\n"))
 628  			}
 629  		}
 630  	}()
 631  
 632  	addr := ln.Addr().String()
 633  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 634  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 635  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 636  	})
 637  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 638  		return gosmtp.Dial(addr)
 639  	})
 640  
 641  	err = client.Send(&OutboundEmail{
 642  		From: "sender@bridge.example.com",
 643  		To:   []string{"alice@example.com"},
 644  		Body: "Test",
 645  	})
 646  	if err == nil {
 647  		t.Fatal("expected error from HELO rejection")
 648  	}
 649  }
 650  
 651  func TestClient_DeliverDirect_WriteDataFails(t *testing.T) {
 652  	// Raw TCP server that accepts DATA but disconnects during body write
 653  	ln, err := net.Listen("tcp", "127.0.0.1:0")
 654  	if err != nil {
 655  		t.Fatalf("listen: %v", err)
 656  	}
 657  	defer ln.Close()
 658  
 659  	go func() {
 660  		conn, err := ln.Accept()
 661  		if err != nil {
 662  			return
 663  		}
 664  		defer conn.Close()
 665  		conn.Write([]byte("220 test ESMTP\r\n"))
 666  		buf := make([]byte, 4096)
 667  		for {
 668  			n, err := conn.Read(buf)
 669  			if err != nil {
 670  				return
 671  			}
 672  			cmd := strings.ToUpper(strings.TrimSpace(string(buf[:n])))
 673  			switch {
 674  			case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
 675  				conn.Write([]byte("250 OK\r\n"))
 676  			case strings.HasPrefix(cmd, "MAIL FROM"):
 677  				conn.Write([]byte("250 OK\r\n"))
 678  			case strings.HasPrefix(cmd, "RCPT TO"):
 679  				conn.Write([]byte("250 OK\r\n"))
 680  			case strings.HasPrefix(cmd, "DATA"):
 681  				conn.Write([]byte("354 Start mail input\r\n"))
 682  				// Immediately close connection to cause write error
 683  				time.Sleep(10 * time.Millisecond)
 684  				conn.Close()
 685  				return
 686  			case strings.HasPrefix(cmd, "QUIT"):
 687  				conn.Write([]byte("221 Bye\r\n"))
 688  				return
 689  			default:
 690  				conn.Write([]byte("500 Unknown\r\n"))
 691  			}
 692  		}
 693  	}()
 694  
 695  	addr := ln.Addr().String()
 696  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 697  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 698  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 699  	})
 700  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 701  		return gosmtp.Dial(addr)
 702  	})
 703  
 704  	err = client.Send(&OutboundEmail{
 705  		From: "sender@bridge.example.com",
 706  		To:   []string{"alice@example.com"},
 707  		Body: "Test body that needs writing",
 708  	})
 709  	if err == nil {
 710  		t.Fatal("expected error from write data failure")
 711  	}
 712  }
 713  
 714  func TestClient_DeliverDirect_MailFromFails(t *testing.T) {
 715  	// Raw TCP server that responds to greeting and EHLO but rejects MAIL FROM
 716  	ln, err := net.Listen("tcp", "127.0.0.1:0")
 717  	if err != nil {
 718  		t.Fatalf("listen: %v", err)
 719  	}
 720  	defer ln.Close()
 721  
 722  	go func() {
 723  		conn, err := ln.Accept()
 724  		if err != nil {
 725  			return
 726  		}
 727  		defer conn.Close()
 728  		conn.Write([]byte("220 test ESMTP\r\n"))
 729  		buf := make([]byte, 1024)
 730  		for {
 731  			n, err := conn.Read(buf)
 732  			if err != nil {
 733  				return
 734  			}
 735  			cmd := strings.ToUpper(strings.TrimSpace(string(buf[:n])))
 736  			switch {
 737  			case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
 738  				conn.Write([]byte("250 OK\r\n"))
 739  			case strings.HasPrefix(cmd, "MAIL FROM"):
 740  				conn.Write([]byte("550 Sender rejected\r\n"))
 741  			case strings.HasPrefix(cmd, "QUIT"):
 742  				conn.Write([]byte("221 Bye\r\n"))
 743  				return
 744  			default:
 745  				conn.Write([]byte("500 Unknown\r\n"))
 746  			}
 747  		}
 748  	}()
 749  
 750  	addr := ln.Addr().String()
 751  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 752  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 753  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 754  	})
 755  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 756  		return gosmtp.Dial(addr)
 757  	})
 758  
 759  	err = client.Send(&OutboundEmail{
 760  		From: "sender@bridge.example.com",
 761  		To:   []string{"alice@example.com"},
 762  		Body: "Test",
 763  	})
 764  	if err == nil {
 765  		t.Fatal("expected error from MAIL FROM rejection")
 766  	}
 767  	if !strings.Contains(err.Error(), "MAIL FROM") {
 768  		t.Logf("error: %v", err)
 769  	}
 770  }
 771  
 772  func TestClient_DeliverDirect_DataFails(t *testing.T) {
 773  	// Raw TCP server that accepts MAIL FROM and RCPT but rejects DATA
 774  	ln, err := net.Listen("tcp", "127.0.0.1:0")
 775  	if err != nil {
 776  		t.Fatalf("listen: %v", err)
 777  	}
 778  	defer ln.Close()
 779  
 780  	go func() {
 781  		conn, err := ln.Accept()
 782  		if err != nil {
 783  			return
 784  		}
 785  		defer conn.Close()
 786  		conn.Write([]byte("220 test ESMTP\r\n"))
 787  		buf := make([]byte, 1024)
 788  		for {
 789  			n, err := conn.Read(buf)
 790  			if err != nil {
 791  				return
 792  			}
 793  			cmd := strings.ToUpper(strings.TrimSpace(string(buf[:n])))
 794  			switch {
 795  			case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
 796  				conn.Write([]byte("250 OK\r\n"))
 797  			case strings.HasPrefix(cmd, "MAIL FROM"):
 798  				conn.Write([]byte("250 OK\r\n"))
 799  			case strings.HasPrefix(cmd, "RCPT TO"):
 800  				conn.Write([]byte("250 OK\r\n"))
 801  			case strings.HasPrefix(cmd, "DATA"):
 802  				conn.Write([]byte("554 Transaction failed\r\n"))
 803  			case strings.HasPrefix(cmd, "QUIT"):
 804  				conn.Write([]byte("221 Bye\r\n"))
 805  				return
 806  			default:
 807  				conn.Write([]byte("500 Unknown\r\n"))
 808  			}
 809  		}
 810  	}()
 811  
 812  	addr := ln.Addr().String()
 813  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 814  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 815  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 816  	})
 817  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 818  		return gosmtp.Dial(addr)
 819  	})
 820  
 821  	err = client.Send(&OutboundEmail{
 822  		From: "sender@bridge.example.com",
 823  		To:   []string{"alice@example.com"},
 824  		Body: "Test",
 825  	})
 826  	if err == nil {
 827  		t.Fatal("expected error from DATA rejection")
 828  	}
 829  }
 830  
 831  func TestClient_DeliverDirect_CloseFails(t *testing.T) {
 832  	// Raw TCP server that accepts DATA but closes connection during write
 833  	ln, err := net.Listen("tcp", "127.0.0.1:0")
 834  	if err != nil {
 835  		t.Fatalf("listen: %v", err)
 836  	}
 837  	defer ln.Close()
 838  
 839  	go func() {
 840  		conn, err := ln.Accept()
 841  		if err != nil {
 842  			return
 843  		}
 844  		defer conn.Close()
 845  		conn.Write([]byte("220 test ESMTP\r\n"))
 846  		buf := make([]byte, 4096)
 847  		for {
 848  			n, err := conn.Read(buf)
 849  			if err != nil {
 850  				return
 851  			}
 852  			cmd := strings.ToUpper(strings.TrimSpace(string(buf[:n])))
 853  			switch {
 854  			case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
 855  				conn.Write([]byte("250 OK\r\n"))
 856  			case strings.HasPrefix(cmd, "MAIL FROM"):
 857  				conn.Write([]byte("250 OK\r\n"))
 858  			case strings.HasPrefix(cmd, "RCPT TO"):
 859  				conn.Write([]byte("250 OK\r\n"))
 860  			case strings.HasPrefix(cmd, "DATA"):
 861  				conn.Write([]byte("354 Start mail input\r\n"))
 862  				// Read until we get \r\n.\r\n (end of data)
 863  				for {
 864  					n, err := conn.Read(buf)
 865  					if err != nil {
 866  						return
 867  					}
 868  					if strings.Contains(string(buf[:n]), "\r\n.\r\n") {
 869  						break
 870  					}
 871  				}
 872  				// Reject at end of data (close error)
 873  				conn.Write([]byte("554 Message rejected\r\n"))
 874  			case strings.HasPrefix(cmd, "QUIT"):
 875  				conn.Write([]byte("221 Bye\r\n"))
 876  				return
 877  			default:
 878  				conn.Write([]byte("500 Unknown\r\n"))
 879  			}
 880  		}
 881  	}()
 882  
 883  	addr := ln.Addr().String()
 884  	client := NewClient(ClientConfig{FromDomain: "bridge.example.com"})
 885  	client.SetResolver(func(domain string) ([]*net.MX, error) {
 886  		return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
 887  	})
 888  	client.SetDialer(func(a string) (*gosmtp.Client, error) {
 889  		return gosmtp.Dial(addr)
 890  	})
 891  
 892  	err = client.Send(&OutboundEmail{
 893  		From: "sender@bridge.example.com",
 894  		To:   []string{"alice@example.com"},
 895  		Body: "Test body content",
 896  	})
 897  	if err == nil {
 898  		t.Fatal("expected error from message rejection")
 899  	}
 900  }
 901  
 902  // Alias used in test function signatures.
 903  var gosmtpDial = gosmtp.Dial
 904