From 3bc33214c37d04966559631171dc66a7bc2e18eb Mon Sep 17 00:00:00 2001 From: dsh Date: Sat, 15 Aug 2026 17:30:40 -0400 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=A4=96=E5=8F=91=20?= =?UTF-8?q?mailer=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88=E7=BA=BF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F/=E7=82=B9=E5=A1=AB=E5=85=85/8BITMIME/?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E5=A4=B1=E8=B4=A5=EF=BC=89=E5=8F=8A=20DKIM?= =?UTF-8?q?=20=E7=AD=BE=E5=90=8D=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/outbound/mailer.go | 12 +- internal/outbound/mailer_test.go | 227 +++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 internal/outbound/mailer_test.go diff --git a/internal/outbound/mailer.go b/internal/outbound/mailer.go index f152f99..4f0e562 100644 --- a/internal/outbound/mailer.go +++ b/internal/outbound/mailer.go @@ -14,6 +14,7 @@ import ( "net" "net/textproto" "sort" + "strconv" "strings" "time" ) @@ -47,6 +48,7 @@ func newPermError(format string, args ...interface{}) *DeliveryError { // Mailer performs direct MX delivery of a single message. type Mailer struct { Hostname string // EHLO hostname presented to remote servers + Port int // destination port, 0 means the default SMTP port 25 ConnectTimeout time.Duration } @@ -58,6 +60,14 @@ func NewMailer(hostname string, connectTimeout time.Duration) *Mailer { return &Mailer{Hostname: hostname, ConnectTimeout: connectTimeout} } +// port returns the destination port, defaulting to 25. +func (m *Mailer) port() int { + if m.Port == 0 { + return 25 + } + return m.Port +} + // Deliver sends one message to one recipient via the recipient domain's MX. // It returns the final SMTP response text on success and a *DeliveryError on // failure. @@ -183,7 +193,7 @@ func (c *smtpClient) hello(hostname string) error { // deliverToHost performs a full SMTP transaction with a single MX host. func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) { - addr := net.JoinHostPort(host, "25") + addr := net.JoinHostPort(host, strconv.Itoa(m.port())) ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout) defer cancel() diff --git a/internal/outbound/mailer_test.go b/internal/outbound/mailer_test.go new file mode 100644 index 0000000..44a35d5 --- /dev/null +++ b/internal/outbound/mailer_test.go @@ -0,0 +1,227 @@ +package outbound + +import ( + "bufio" + "net" + "strings" + "testing" + "time" +) + +// fakeSMTPServer captures a single SMTP transaction for inspection. +type fakeSMTPServer struct { + ln net.Listener + done chan struct{} + gotData []byte + cmds []string + err error +} + +func startFakeSMTPServer(t *testing.T) *fakeSMTPServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + f := &fakeSMTPServer{ln: ln, done: make(chan struct{})} + go f.serve() + t.Cleanup(func() { + ln.Close() + <-f.done + }) + return f +} + +func (f *fakeSMTPServer) serve() { + defer close(f.done) + conn, err := f.ln.Accept() + if err != nil { + f.err = err + return + } + defer conn.Close() + + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + + write := func(s string) { + _, _ = w.WriteString(s) + _ = w.Flush() + } + + write("220 fake.test ESMTP ready\r\n") + + for { + line, err := r.ReadString('\n') + if err != nil { + f.err = err + return + } + line = strings.TrimRight(line, "\r\n") + f.cmds = append(f.cmds, line) + + switch { + case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"): + write("250-fake.test\r\n250 8BITMIME\r\n") + case strings.HasPrefix(strings.ToUpper(line), "MAIL FROM"): + write("250 2.0.0 ok\r\n") + case strings.HasPrefix(strings.ToUpper(line), "RCPT TO"): + write("250 2.0.0 ok\r\n") + case strings.HasPrefix(strings.ToUpper(line), "DATA"): + write("354 go ahead\r\n") + // Read until the terminating dot line, un-stuffing dot lines + // exactly like a real SMTP receiver. + for { + dl, err := r.ReadString('\n') + if err != nil { + f.err = err + return + } + if strings.TrimRight(dl, "\r\n") == "." { + break + } + if strings.HasPrefix(dl, "..") { + dl = dl[1:] + } + f.gotData = append(f.gotData, []byte(dl)...) + } + write("250 2.0.0 queued\r\n") + case strings.HasPrefix(strings.ToUpper(line), "QUIT"): + write("221 bye\r\n") + return + default: + write("500 huh\r\n") + } + } +} + +func (f *fakeSMTPServer) port() int { + return f.ln.Addr().(*net.TCPAddr).Port +} + +func TestMailerWireFormat(t *testing.T) { + f := startFakeSMTPServer(t) + + m := NewMailer("mail.lmve.net", 10*time.Second) + m.Port = f.port() + + input := []byte("From: a@lmve.net\r\n" + + "To: b@fake.test\r\n" + + "Subject: wire test\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "line one\r\n" + + ".leading dot must be stuffed\r\n" + + "line three\r\n") + + resp, err := m.deliverToHost("127.0.0.1", "a@lmve.net", "b@fake.test", input) + if err != nil { + t.Fatalf("deliverToHost: %v", err) + } + if !strings.HasPrefix(resp, "250") { + t.Fatalf("unexpected response: %q", resp) + } + + if f.err != nil { + t.Fatalf("fake server error: %v", f.err) + } + if len(f.gotData) == 0 { + t.Fatal("no DATA received") + } + + // The captured data must be dot-UNstuffed: identical to the input. + // (The fake server un-stuffs dot lines like a real receiver, so a match + // here proves the mailer applied correct dot-stuffing on the wire.) + if string(f.gotData) != string(input) { + t.Fatalf("wire data mismatch.\ngot: %q\nwant: %q", f.gotData, input) + } +} + +func TestMailer8BitMIME(t *testing.T) { + f := startFakeSMTPServer(t) + + m := NewMailer("mail.lmve.net", 10*time.Second) + m.Port = f.port() + + // 8-bit body (UTF-8) with a server that advertises 8BITMIME. + input := []byte("From: a@lmve.net\r\nTo: b@fake.test\r\nSubject: 8bit\r\n\r\n你好世界\r\n") + if _, err := m.deliverToHost("127.0.0.1", "a@lmve.net", "b@fake.test", input); err != nil { + t.Fatalf("deliverToHost: %v", err) + } + + found := false + for _, cmd := range f.cmds { + if strings.HasPrefix(strings.ToUpper(cmd), "MAIL FROM") { + if strings.Contains(strings.ToUpper(cmd), "BODY=8BITMIME") { + found = true + } + } + } + if !found { + t.Fatalf("expected MAIL FROM with BODY=8BITMIME, got: %v", f.cmds) + } +} + +func TestMailerPermanentFailure(t *testing.T) { + // A fake server that rejects the recipient with 550. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + _, _ = w.WriteString("220 reject.test ESMTP\r\n") + _ = w.Flush() + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + switch { + case strings.HasPrefix(strings.ToUpper(line), "EHLO"): + _, _ = w.WriteString("250 reject.test\r\n") + _ = w.Flush() + case strings.HasPrefix(strings.ToUpper(line), "MAIL"): + _, _ = w.WriteString("250 ok\r\n") + _ = w.Flush() + case strings.HasPrefix(strings.ToUpper(line), "RCPT"): + _, _ = w.WriteString("550 5.1.1 no such user\r\n") + _ = w.Flush() + case strings.HasPrefix(strings.ToUpper(line), "QUIT"): + _, _ = w.WriteString("221 bye\r\n") + _ = w.Flush() + return + } + } + }() + + m := NewMailer("mail.lmve.net", 10*time.Second) + m.Port = ln.Addr().(*net.TCPAddr).Port + + input := []byte("From: a@lmve.net\r\nTo: b@reject.test\r\nSubject: t\r\n\r\nbody\r\n") + _, err = m.deliverToHost("127.0.0.1", "a@lmve.net", "b@reject.test", input) + if err == nil { + t.Fatal("expected error for 550 rejection") + } + de, ok := err.(*DeliveryError) + if !ok { + t.Fatalf("expected *DeliveryError, got %T: %v", err, err) + } + if !de.Permanent { + t.Fatalf("expected permanent error, got %+v", de) + } + if de.Code != 550 { + t.Fatalf("expected code 550, got %d", de.Code) + } + <-done +}