From eba1b9d10153ab685ac657809f8fcc9650ef2df3 Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 2 Oct 2026 14:15:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=82=AE=E4=BB=B6=E5=A4=96?= =?UTF-8?q?=E5=9F=9F=E5=A4=9A=E5=9C=B0=E5=9D=80=E8=BF=9E=E6=8E=A5=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E5=B9=B6=E4=BF=9D=E7=95=99=E5=A4=B1=E8=B4=A5=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/utils/smtp/context_test.go | 59 ++++++++++++++++++++++++++++++ backend/utils/smtp/smtp.go | 31 ++++++++++++---- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/backend/utils/smtp/context_test.go b/backend/utils/smtp/context_test.go index 94fafec..63934f9 100644 --- a/backend/utils/smtp/context_test.go +++ b/backend/utils/smtp/context_test.go @@ -2,11 +2,70 @@ package smtp import ( "context" + "errors" "net" + "net/netip" "testing" "time" ) +func TestDialResolvedAddressesFallsBackAfterTimeout(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1")} + calls := 0 + conn, err := dialResolvedAddresses(context.Background(), addresses, "25", 10*time.Millisecond, func(ctx context.Context, network, address string) (net.Conn, error) { + calls++ + if network != "tcp" || address != net.JoinHostPort(addresses[calls-1].String(), "25") { + t.Fatalf("unexpected dial target: %s %s", network, address) + } + if calls == 1 { + <-ctx.Done() + return nil, ctx.Err() + } + if ctx.Err() != nil { + t.Fatal("fallback inherited exhausted attempt context") + } + return client, nil + }) + if err != nil || conn != client || calls != 2 { + t.Fatalf("fallback failed: conn=%v calls=%d err=%v", conn, calls, err) + } +} + +func TestDialResolvedAddressesHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.2")} + calls := 0 + _, err := dialResolvedAddresses(ctx, addresses, "25", time.Second, func(context.Context, string, string) (net.Conn, error) { + calls++ + cancel() + return nil, context.Canceled + }) + if calls != 1 || !errors.Is(err, context.Canceled) { + t.Fatalf("canceled parent was ignored: calls=%d err=%v", calls, err) + } +} + +func TestDialResolvedAddressesRetainsFailures(t *testing.T) { + first := errors.New("first address failed") + second := errors.New("second address failed") + addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.2")} + calls := 0 + _, err := dialResolvedAddresses(context.Background(), addresses, "25", time.Second, func(context.Context, string, string) (net.Conn, error) { + calls++ + if calls == 1 { + return nil, first + } + return nil, second + }) + if calls != 2 || !errors.Is(err, first) || !errors.Is(err, second) { + t.Fatalf("connection failures were lost: calls=%d err=%v", calls, err) + } +} + func TestSMTPContextCancelsGreeting(t *testing.T) { client, server := net.Pipe() defer server.Close() diff --git a/backend/utils/smtp/smtp.go b/backend/utils/smtp/smtp.go index 27322e9..02b3b17 100644 --- a/backend/utils/smtp/smtp.go +++ b/backend/utils/smtp/smtp.go @@ -62,7 +62,8 @@ func IsDeliveryUncertain(err error) bool { } const ( - outboundSMTPDialTimeout = 2 * time.Second + outboundSMTPDialTimeout = 10 * time.Second + outboundSMTPConnectTimeout = 30 * time.Second outboundSMTPGreetingTimeout = 5 * time.Second outboundSMTPTLSHandshakeTimeout = 2 * time.Second outboundSMTPTransactionTimeout = 2 * time.Minute @@ -215,9 +216,11 @@ func dialTCPContext(parent context.Context, addr string, publicOnly bool) (net.C return nil, errors.New("smtp: invalid remote address") } - ctx, cancel := context.WithTimeout(parent, outboundSMTPDialTimeout) + ctx, cancel := context.WithTimeout(parent, outboundSMTPConnectTimeout) defer cancel() - addresses, err := resolveRemoteAddresses(ctx, host) + resolveCtx, resolveCancel := context.WithTimeout(ctx, outboundSMTPDialTimeout) + addresses, err := resolveRemoteAddresses(resolveCtx, host) + resolveCancel() if err != nil { return nil, err } @@ -228,18 +231,30 @@ func dialTCPContext(parent context.Context, addr string, publicOnly bool) (net.C } dialer := &net.Dialer{Timeout: outboundSMTPDialTimeout, KeepAlive: 30 * time.Second} - var lastErr error + return dialResolvedAddresses(ctx, addresses, port, outboundSMTPDialTimeout, dialer.DialContext) +} + +// 为各地址分配独立连接预算,同时遵守父级总时限,避免首个地址耗尽全部回退时间。 +func dialResolvedAddresses(ctx context.Context, addresses []netip.Addr, port string, timeout time.Duration, dial func(context.Context, string, string) (net.Conn, error)) (net.Conn, error) { + var failures []error for _, address := range addresses { - conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(address.String(), port)) + if err := ctx.Err(); err != nil { + failures = append(failures, err) + break + } + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + conn, err := dial(attemptCtx, "tcp", net.JoinHostPort(address.String(), port)) + cancel() if err == nil { return conn, nil } - lastErr = err + failures = append(failures, err) } - if lastErr == nil { + if len(failures) == 0 { return nil, errors.New("smtp: remote address is unavailable") } - return nil, fmt.Errorf("smtp: connect to remote address: %w", lastErr) + // 保留各地址的失败原因,不能把最后一个未成功连接的地址当成唯一故障。 + return nil, fmt.Errorf("smtp: connect to remote address: %w", errors.Join(failures...)) } func resolveRemoteAddresses(ctx context.Context, host string) ([]netip.Addr, error) {