mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 17:16:07 +00:00
fix(email): bound every SMTP step with a connection deadline
The "starttls"/"none" transport delivered through net/smtp.SendMail, which dials with an untimed net.Dial and never sets a socket deadline. When an SMTP server accepted the TCP connection but then stalled (or was a blackhole), the caller was released by Send's 30s select, but the sender goroutine and its socket stayed blocked until the OS TCP timeout — minutes per notification, leaking a goroutine and a connection each time. sendWithTLS dialed with a timeout but likewise armed no deadline on the protocol phase, and TestConnection (called synchronously from the settings handler, with no select guard) could hang the request indefinitely. Replace SendMail with sendPlain, which dials with smtpConnectTimeout and arms conn.SetDeadline(smtpDeadline) before the greeting read, preserving SendMail's opportunistic STARTTLS upgrade. Arm the same deadline in sendWithTLS and TestConnection so every SMTP step is bounded.
This commit is contained in:
@@ -33,6 +33,15 @@ func NewEmailService(settingService service.SettingService) *EmailService {
|
||||
return &EmailService{settingService: settingService}
|
||||
}
|
||||
|
||||
// smtpConnectTimeout bounds the TCP dial. smtpDeadline bounds every SMTP
|
||||
// protocol step after the connection is up, so a server that accepts the socket
|
||||
// but then stalls cannot block the sender goroutine (and leak its socket) long
|
||||
// after the caller's own timeout has already fired. smtpDeadline is a var only
|
||||
// so tests can shorten it.
|
||||
const smtpConnectTimeout = 10 * time.Second
|
||||
|
||||
var smtpDeadline = 30 * time.Second
|
||||
|
||||
// Send sends an HTML email to all configured recipients.
|
||||
func (s *EmailService) Send(subject, body string) error {
|
||||
host, err := s.settingService.GetSmtpHost()
|
||||
@@ -82,7 +91,7 @@ func (s *EmailService) Send(subject, body string) error {
|
||||
case "tls":
|
||||
ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
|
||||
case "starttls", "none":
|
||||
ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
|
||||
ch <- result{s.sendPlain(addr, auth, from, recipients, msg, host)}
|
||||
default:
|
||||
ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
|
||||
}
|
||||
@@ -147,6 +156,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
return SMTPTestResult{false, "connect", classifySMTPError(err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
// Stage 2: Handshake + Auth
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
@@ -207,7 +217,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
|
||||
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
// Dial with explicit timeout
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
dialer := &net.Dialer{Timeout: smtpConnectTimeout}
|
||||
conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
@@ -216,6 +226,7 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
@@ -249,6 +260,56 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// sendPlain delivers over a plain TCP connection, opportunistically upgrading
|
||||
// via STARTTLS when the server advertises it (the behavior net/smtp.SendMail
|
||||
// gives the "starttls" and "none" transports). Unlike SendMail it dials with a
|
||||
// timeout and arms a connection deadline, so a server that never speaks or
|
||||
// stalls mid-protocol cannot block the sender goroutine past smtpDeadline.
|
||||
func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range to {
|
||||
if err = client.Rcpt(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// SendTest sends a test email and returns any error with detail.
|
||||
func (s *EmailService) SendTest() error {
|
||||
return s.Send(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A server that accepts the TCP connection but then never speaks must not block
|
||||
// the sender goroutine indefinitely: sendPlain arms a connection deadline so the
|
||||
// SMTP greeting read fails instead of hanging until the OS TCP timeout, long
|
||||
// after the caller's own 30s budget has passed.
|
||||
func TestSendPlainReturnsOnStalledServer(t *testing.T) {
|
||||
orig := smtpDeadline
|
||||
smtpDeadline = 300 * time.Millisecond
|
||||
t.Cleanup(func() { smtpDeadline = orig })
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
stall := make(chan struct{})
|
||||
defer close(stall)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
<-stall
|
||||
}()
|
||||
|
||||
s := &EmailService{}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- s.sendPlain(ln.Addr().String(), nil, "from@example.com",
|
||||
[]string{"to@example.com"}, []byte("body"), "example.com")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a silent SMTP server, got nil")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("sendPlain did not return on a stalled server within the deadline")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user