From 7881935f54b02d00cd61960e1908c0e8e5b12e93 Mon Sep 17 00:00:00 2001 From: dajiaohuang <108231307+dajiaohuang@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:33:25 +0800 Subject: [PATCH] Fix IPv6 remote address formatting --- remotes.go | 3 +- remotes_test.go | 20 +++++++++++ smtp_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 smtp_test.go diff --git a/remotes.go b/remotes.go index 486bef5..5e5783a 100644 --- a/remotes.go +++ b/remotes.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net" "net/smtp" "net/url" ) @@ -55,7 +56,7 @@ func ParseRemote(remoteURL string) (*Remote, error) { Scheme: u.Scheme, Hostname: hostname, Port: port, - Addr: fmt.Sprintf("%s:%s", hostname, port), + Addr: net.JoinHostPort(hostname, port), } if u.User != nil { diff --git a/remotes_test.go b/remotes_test.go index 74ca960..0d11fe0 100644 --- a/remotes_test.go +++ b/remotes_test.go @@ -107,6 +107,26 @@ func TestValidRemoteUrls(t *testing.T) { }, "starttls://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify") } +func TestIPv6RemoteUrls(t *testing.T) { + for _, test := range []struct { + url, scheme, port string + }{ + {"smtp://[::1]", "smtp", "25"}, + {"smtps://[::1]", "smtps", "465"}, + {"starttls://[::1]", "starttls", "587"}, + {"smtp://[::1]:2525", "smtp", "2525"}, + } { + t.Run(test.url, func(t *testing.T) { + AssertRemoteUrlEquals(t, &Remote{ + Scheme: test.scheme, + Hostname: "::1", + Port: test.port, + Addr: "[::1]:" + test.port, + }, test.url) + }) + } +} + func TestMissingScheme(t *testing.T) { _, err := ParseRemote("http://user:pass@email.com:8425/sender@website.com") assert.NotNil(t, err, "Err must be present") diff --git a/smtp_test.go b/smtp_test.go new file mode 100644 index 0000000..0249199 --- /dev/null +++ b/smtp_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "net" + "net/textproto" + "testing" + "time" +) + +func TestSendMailIPv6Remote(t *testing.T) { + listener, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback unavailable: %v", err) + } + defer listener.Close() + if err := listener.(*net.TCPListener).SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + + result := make(chan error, 1) + go func() { + result <- serveTestSMTP(listener) + }() + + remote, err := ParseRemote("smtp://" + listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + if err := SendMail(remote, "sender@example.invalid", []string{"recipient@example.invalid"}, []byte("Subject: loopback test\r\n\r\nsynthetic fixture\r\n")); err != nil { + t.Fatal(err) + } + if err := <-result; err != nil { + t.Fatal(err) + } +} + +// This sink accepts only the synthetic transaction used above, never relaying it. +func serveTestSMTP(listener net.Listener) error { + conn, err := listener.Accept() + if err != nil { + return err + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return err + } + text := textproto.NewConn(conn) + if err := text.PrintfLine("220 loopback test"); err != nil { + return err + } + commands := []struct{ command, response string }{ + {"EHLO " + *hostName, "250 loopback test"}, + {"MAIL FROM:", "250 OK"}, + {"RCPT TO:", "250 OK"}, + {"DATA", "354 End with a dot"}, + } + for _, step := range commands { + line, err := text.ReadLine() + if err != nil { + return err + } + if line != step.command { + return fmt.Errorf("command = %q, want %q", line, step.command) + } + if err := text.PrintfLine("%s", step.response); err != nil { + return err + } + } + data, err := text.ReadDotBytes() + if err != nil { + return err + } + if string(data) != "Subject: loopback test\n\nsynthetic fixture\n" { + return fmt.Errorf("unexpected DATA: %q", data) + } + if err := text.PrintfLine("250 OK"); err != nil { + return err + } + line, err := text.ReadLine() + if err != nil { + return err + } + if line != "QUIT" { + return fmt.Errorf("command = %q, want QUIT", line) + } + return text.PrintfLine("221 Bye") +}