package utility import ( "context" "fmt" "io" "net" "net/textproto" "strings" "testing" "time" ) func TestExternalRequestTimeoutDefaults(t *testing.T) { for _, value := range []string{"", "invalid", "0s", "-1s"} { if got := ExternalRequestTimeout(value); got != DefaultExternalRequestTimeout { t.Errorf("invalid timeout %q resolved to %v", value, got) } } if got := ExternalRequestTimeout(" 30s "); got != 30*time.Second { t.Fatalf("configured timeout ignored: %v", got) } } func startSMTPTestServer(t *testing.T, handle func(net.Conn)) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } done := make(chan struct{}) go func() { defer close(done) conn, err := listener.Accept() if err != nil { return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) handle(conn) }() t.Cleanup(func() { _ = listener.Close() select { case <-done: case <-time.After(3 * time.Second): t.Error("SMTP server connection was not released") } }) return listener.Addr().String() } func TestSMTPSuccessAndRejectedRecipient(t *testing.T) { for _, reject := range []bool{false, true} { t.Run(fmt.Sprintf("reject=%t", reject), func(t *testing.T) { address := startSMTPTestServer(t, func(conn net.Conn) { peer := textproto.NewConn(conn) _ = peer.PrintfLine("220 localhost ready") for { line, err := peer.ReadLine() if err != nil { return } switch { case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"), strings.HasPrefix(line, "MAIL"): _ = peer.PrintfLine("250 OK") case strings.HasPrefix(line, "RCPT"): if reject { _ = peer.PrintfLine("550 recipient rejected") return } _ = peer.PrintfLine("250 OK") case line == "DATA": _ = peer.PrintfLine("354 send message") if _, err := peer.ReadDotBytes(); err != nil { return } _ = peer.PrintfLine("250 accepted") case line == "QUIT": _ = peer.PrintfLine("221 bye") return } } }) err := SendSMTP(context.Background(), SMTPOptions{ Address: address, Host: "localhost", From: "xxx@example.com", To: []string{"xxx@example.com"}, Data: []byte("Subject: test\r\n\r\nmessage\r\n"), Timeout: time.Second, }) if (err != nil) != reject { t.Fatalf("unexpected SMTP result: rejected=%t error=%v", reject, err) } }) } } func TestSMTPTimeoutInterruptsGreetingTLSAndProtocol(t *testing.T) { for _, scenario := range []string{"greeting", "tls", "mail", "starttls"} { t.Run(scenario, func(t *testing.T) { address := startSMTPTestServer(t, func(conn net.Conn) { if scenario == "greeting" || scenario == "tls" { _, _ = io.Copy(io.Discard, conn) return } peer := textproto.NewConn(conn) _ = peer.PrintfLine("220 localhost ready") if _, err := peer.ReadLine(); err != nil { return } if scenario == "starttls" { _ = peer.PrintfLine("250-localhost") _ = peer.PrintfLine("250 STARTTLS") if _, err := peer.ReadLine(); err != nil { return } _ = peer.PrintfLine("220 start TLS") } else { _ = peer.PrintfLine("250 OK") } _, _ = io.Copy(io.Discard, conn) }) started := time.Now() err := SendSMTP(context.Background(), SMTPOptions{ Address: address, Host: "localhost", SSL: scenario == "tls", From: "xxx@example.com", To: []string{"xxx@example.com"}, Data: []byte("message"), Timeout: 70 * time.Millisecond, }) if err == nil || time.Since(started) > time.Second { t.Fatalf("SMTP stall was not interrupted: %v", err) } }) } } func TestSMTPHonorsEarlierParentDeadline(t *testing.T) { address := startSMTPTestServer(t, func(conn net.Conn) { _, _ = io.Copy(io.Discard, conn) }) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() started := time.Now() err := SendSMTP(ctx, SMTPOptions{Address: address, Host: "localhost", Timeout: 10 * time.Second}) if err == nil || time.Since(started) > time.Second { t.Fatalf("parent deadline was not honored: %v", err) } }