Smail/server/utils/smtp/smtp_test.go
0clickjacking0 1da3e178bf
fix(smtp): 修复下游投递失败后 SMTP DATA 仍返回成功,导致上游误判为已发送 (#356)
* fix(smtp): propagate downstream delivery failures

* test(smtp): support Go 1.26 textproto errors
2026-08-17 10:40:52 +08:00

183 lines
4.3 KiB
Go

package smtp
import (
"errors"
"fmt"
"io"
"net"
"net/textproto"
"strings"
"testing"
"time"
)
func TestSendMailUnsafeUsesFinalDataResponseAsDeliveryResult(t *testing.T) {
tests := []struct {
name string
dataResponse string
holdWithoutQuitReply bool
wantCode int
maxDuration time.Duration
}{
{
name: "accepted message returns while server withholds QUIT reply",
dataResponse: "250 2.0.0 queued",
holdWithoutQuitReply: true,
maxDuration: time.Second,
},
{
name: "DATA rejection remains a delivery failure",
dataResponse: "550 5.0.0 rejected",
wantCode: 550,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr, serverDone := startSMTPTransactionServer(t, tt.dataResponse, tt.holdWithoutQuitReply)
start := time.Now()
err := SendMailUnsafe(
"",
addr,
nil,
"sender@example.com",
"example.com",
[]string{"recipient@example.net"},
[]byte("From: sender@example.com\r\nTo: recipient@example.net\r\nSubject: test\r\n\r\nbody\r\n"),
)
duration := time.Since(start)
if tt.maxDuration > 0 && duration > tt.maxDuration {
t.Fatalf("SendMailUnsafe() took %v, want at most %v", duration, tt.maxDuration)
}
if tt.wantCode == 0 {
if err != nil {
t.Fatalf("SendMailUnsafe() = %v, want nil", err)
}
} else {
var protocolErr *textproto.Error
if !errors.As(err, &protocolErr) {
t.Fatalf("SendMailUnsafe() error type = %T, want *textproto.Error", err)
}
if protocolErr.Code != tt.wantCode {
t.Fatalf("SMTP code = %d, want %d", protocolErr.Code, tt.wantCode)
}
}
select {
case serverErr := <-serverDone:
if serverErr != nil {
t.Fatal(serverErr)
}
case <-time.After(2 * time.Second):
t.Fatal("fake SMTP server did not finish")
}
})
}
}
func startSMTPTransactionServer(t *testing.T, dataResponse string, holdWithoutQuitReply bool) (string, <-chan error) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
done := make(chan error, 1)
go func() {
defer listener.Close()
conn, err := listener.Accept()
if err != nil {
done <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
text := textproto.NewConn(conn)
defer text.Close()
if err := text.PrintfLine("220 test ESMTP ready"); err != nil {
done <- err
return
}
if err := expectSMTPCommand(text, "EHLO "); err != nil {
done <- err
return
}
if err := text.PrintfLine("250 test"); err != nil {
done <- err
return
}
if err := expectSMTPCommand(text, "MAIL FROM:"); err != nil {
done <- err
return
}
if err := text.PrintfLine("250 2.1.0 sender accepted"); err != nil {
done <- err
return
}
if err := expectSMTPCommand(text, "RCPT TO:"); err != nil {
done <- err
return
}
if err := text.PrintfLine("250 2.1.5 recipient accepted"); err != nil {
done <- err
return
}
if err := expectSMTPCommand(text, "DATA"); err != nil {
done <- err
return
}
if err := text.PrintfLine("354 end data with <CR><LF>.<CR><LF>"); err != nil {
done <- err
return
}
body, err := io.ReadAll(text.DotReader())
if err != nil {
done <- err
return
}
if !strings.Contains(string(body), "Subject: test") {
done <- fmt.Errorf("message body was not received: %q", body)
return
}
if err := text.PrintfLine("%s", dataResponse); err != nil {
done <- err
return
}
if holdWithoutQuitReply {
if err := expectSMTPCommand(text, "QUIT"); err != nil {
done <- err
return
}
// Keep the server side open without sending 221. The client must close
// the accepted transaction without waiting for a reply.
buf := make([]byte, 1)
if _, err := conn.Read(buf); err != io.EOF {
done <- fmt.Errorf("waiting for client close after QUIT: %w", err)
return
}
}
done <- nil
}()
return listener.Addr().String(), done
}
func expectSMTPCommand(conn *textproto.Conn, prefix string) error {
line, err := conn.ReadLine()
if err != nil {
return err
}
if !strings.HasPrefix(line, prefix) {
return fmt.Errorf("SMTP command = %q, want prefix %q", line, prefix)
}
return nil
}