diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index 9f1135b..ce03a17 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -1,6 +1,7 @@ package verification import ( + "context" "errors" "fmt" "mime" @@ -9,6 +10,7 @@ import ( "net/smtp" "strconv" "strings" + "time" "git.pcux.cn/xuans/Smail/services/logstore" smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp" @@ -17,6 +19,7 @@ import ( type SecurityMode string const ( + MailDeliveryTimeout = 45 * time.Second SecurityStartTLS SecurityMode = "starttls" SecurityImplicitTLS SecurityMode = "tls" SecurityPlain SecurityMode = "plain" @@ -166,6 +169,13 @@ func isValidSMTPHost(host string) bool { } func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) error { + ctx, cancel := context.WithTimeout(context.Background(), MailDeliveryTimeout) + defer cancel() + return SendSMTPMessageContext(ctx, settings, recipient, message) +} + +// SendSMTPMessageContext 允许验证码中继与后备投递共享同一总时限。 +func SendSMTPMessageContext(ctx context.Context, settings SMTPSettings, recipient string, message []byte) error { addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port)) var auth smtp.Auth if settings.Username != "" { @@ -180,11 +190,11 @@ func sendSMTPMessage(settings SMTPSettings, recipient string, message []byte) er } switch settings.Security { case SecurityStartTLS: - return smailsmtp.SendMail(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) + return smailsmtp.SendMailContext(ctx, settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) case SecurityImplicitTLS: - return smailsmtp.SendMailWithTls(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) + return smailsmtp.SendMailWithTLSContext(ctx, settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) case SecurityPlain: - return smailsmtp.SendMailUnsafe(settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) + return smailsmtp.SendMailUnsafeContext(ctx, settings.Host, addr, auth, settings.From, fromDomain, []string{recipient}, message) default: return ErrUnsupportedSMTP } diff --git a/backend/utils/send/routing.go b/backend/utils/send/routing.go index 7cb7fa6..4207830 100644 --- a/backend/utils/send/routing.go +++ b/backend/utils/send/routing.go @@ -18,7 +18,11 @@ var errNullMX = errors.New("收件域名声明不接收邮件") type mxLookup func(stdcontext.Context, string) ([]*net.MX, error) func resolveMX(domain string, lookup mxLookup) ([]string, error) { - ctx, cancel := stdcontext.WithTimeout(stdcontext.Background(), 10*time.Second) + return resolveMXContext(stdcontext.Background(), domain, lookup) +} + +func resolveMXContext(parent stdcontext.Context, domain string, lookup mxLookup) ([]string, error) { + ctx, cancel := stdcontext.WithTimeout(parent, 10*time.Second) defer cancel() records, err := lookup(ctx, domain) if err != nil { @@ -60,9 +64,16 @@ func stopFallback(err error) bool { // deliverMX 只在确认本次未接收时切换安全通道,DATA 不确定结果不进入下一条路由。 func deliverMX(hosts []string, attempt func(host, port string) error) error { + return deliverMXContext(stdcontext.Background(), hosts, attempt) +} + +func deliverMXContext(ctx stdcontext.Context, hosts []string, attempt func(host, port string) error) error { var lastErr error for _, host := range hosts { for _, port := range []string{"25", "587", "465"} { + if err := ctx.Err(); err != nil { + return err + } lastErr = attempt(host, port) if lastErr == nil || stopFallback(lastErr) { return lastErr diff --git a/backend/utils/send/send.go b/backend/utils/send/send.go index 3fdc613..c522d7e 100644 --- a/backend/utils/send/send.go +++ b/backend/utils/send/send.go @@ -1,6 +1,7 @@ package send import ( + stdcontext "context" "crypto/tls" "errors" "fmt" @@ -152,6 +153,10 @@ func parseRecipientAddress(user *parsemail.User) (string, string, error) { } func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemail.User, from string, triggers ...string) (sendErr error, recipientErrors map[string]error) { + deliveryContext := stdcontext.Background() + if ctx != nil && ctx.Context != nil { + deliveryContext = ctx.Context + } var accepted atomic.Int64 trigger := "邮件发送" if len(triggers) > 0 { @@ -243,7 +248,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai }() if domain == consts.TEST_DOMAIN { - err := smtp.SendMailUnsafe("localhost", "127.0.0.1:25", nil, from, fromDomain, buildAddress(tos), data) + err := smtp.SendMailUnsafeContext(deliveryContext, "localhost", "127.0.0.1:25", nil, from, fromDomain, buildAddress(tos), data) if err != nil { recordFailure(err) } else { @@ -252,17 +257,17 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai return } - hosts, err := resolveMX(domain, net.DefaultResolver.LookupMX) + hosts, err := resolveMXContext(deliveryContext, domain, net.DefaultResolver.LookupMX) if err != nil { recordFailure(err) return } - err = deliverMX(hosts, func(host, port string) error { + err = deliverMXContext(deliveryContext, hosts, func(host, port string) error { address := net.JoinHostPort(host, port) if port == "465" { - return smtp.SendMailWithTls(host, address, nil, from, fromDomain, buildAddress(tos), data) + return smtp.SendMailWithTLSContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) } - return smtp.SendMail(host, address, nil, from, fromDomain, buildAddress(tos), data) + return smtp.SendMailContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) }) if err != nil { recordFailure(err) diff --git a/backend/utils/smtp/smtp.go b/backend/utils/smtp/smtp.go index 9d2f6b0..27322e9 100644 --- a/backend/utils/smtp/smtp.go +++ b/backend/utils/smtp/smtp.go @@ -63,6 +63,7 @@ func IsDeliveryUncertain(err error) bool { const ( outboundSMTPDialTimeout = 2 * time.Second + outboundSMTPGreetingTimeout = 5 * time.Second outboundSMTPTLSHandshakeTimeout = 2 * time.Second outboundSMTPTransactionTimeout = 2 * time.Minute outboundSMTPQuitWriteTimeout = 250 * time.Millisecond @@ -89,6 +90,8 @@ type Client struct { localName string // the name to use in HELO/EHLO didHello bool // whether we've said HELO/EHLO helloError error // the error from the hello + ctx context.Context + stopCancel func() bool } // Dial returns a new Client connected to an SMTP server at addr. @@ -98,7 +101,11 @@ func Dial(addr, fromDomain string) (*Client, error) { } func dial(addr, fromDomain string, publicOnly bool) (*Client, error) { - conn, err := dialTCP(addr, publicOnly) + return dialContext(context.Background(), addr, fromDomain, publicOnly) +} + +func dialContext(ctx context.Context, addr, fromDomain string, publicOnly bool) (*Client, error) { + conn, err := dialTCPContext(ctx, addr, publicOnly) if err != nil { return nil, err } @@ -107,7 +114,7 @@ func dial(addr, fromDomain string, publicOnly bool) (*Client, error) { _ = conn.Close() return nil, err } - return newClientWithTimeout(conn, host, fromDomain, outboundSMTPTransactionTimeout) + return newClientWithContext(ctx, conn, host, fromDomain) } // with tls @@ -116,6 +123,10 @@ func DialTls(addr, domain, fromDomain string) (*Client, error) { } func dialTLS(addr, domain, fromDomain string, publicOnly bool) (*Client, error) { + return dialTLSContext(context.Background(), addr, domain, fromDomain, publicOnly) +} + +func dialTLSContext(ctx context.Context, addr, domain, fromDomain string, publicOnly bool) (*Client, error) { host, _, err := net.SplitHostPort(addr) if err != nil { return nil, err @@ -129,16 +140,50 @@ func dialTLS(addr, domain, fromDomain string, publicOnly bool) (*Client, error) ServerName: domain, } - conn, err := dialTCP(addr, publicOnly) + conn, err := dialTCPContext(ctx, addr, publicOnly) if err != nil { return nil, err } + stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopCancel() tlsConn, err := handshakeTLS(conn, tlsconfig, outboundSMTPTLSHandshakeTimeout) if err != nil { _ = conn.Close() return nil, err } - return newClientWithTimeout(tlsConn, host, fromDomain, outboundSMTPTransactionTimeout) + return newClientWithContext(ctx, tlsConn, host, fromDomain) +} + +// newClientWithContext 将欢迎响应、命令和 TLS 升级限制在同一次投递时限内。 +func newClientWithContext(ctx context.Context, conn net.Conn, host, fromDomain string) (*Client, error) { + stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) + if err := conn.SetDeadline(smtpDeadline(ctx, outboundSMTPGreetingTimeout)); err != nil { + stopCancel() + _ = conn.Close() + return nil, err + } + client, err := NewClient(conn, host, fromDomain) + if err != nil { + stopCancel() + return nil, err + } + client.ctx = ctx + client.stopCancel = stopCancel + if err := conn.SetDeadline(smtpDeadline(ctx, outboundSMTPTransactionTimeout)); err != nil { + _ = client.Close() + return nil, err + } + return client, nil +} + +func smtpDeadline(ctx context.Context, timeout time.Duration) time.Time { + deadline := time.Now().Add(timeout) + if ctx != nil { + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + return limit + } + } + return deadline } func handshakeTLS(conn net.Conn, config *tls.Config, timeout time.Duration) (*tls.Conn, error) { @@ -161,12 +206,16 @@ func newClientWithTimeout(conn net.Conn, host, fromDomain string, timeout time.D } func dialTCP(addr string, publicOnly bool) (net.Conn, error) { + return dialTCPContext(context.Background(), addr, publicOnly) +} + +func dialTCPContext(parent context.Context, addr string, publicOnly bool) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil || host == "" || port == "" { return nil, errors.New("smtp: invalid remote address") } - ctx, cancel := context.WithTimeout(context.Background(), outboundSMTPDialTimeout) + ctx, cancel := context.WithTimeout(parent, outboundSMTPDialTimeout) defer cancel() addresses, err := resolveRemoteAddresses(ctx, host) if err != nil { @@ -326,6 +375,9 @@ func NewClient(conn net.Conn, host, fromDomain string) (*Client, error) { // Close closes the connection. func (c *Client) Close() error { + if c.stopCancel != nil { + c.stopCancel() + } return c.Text.Close() } @@ -426,7 +478,7 @@ func (c *Client) StartTLS(config *tls.Config) error { if err != nil { return err } - if err = tlsConn.SetDeadline(time.Now().Add(outboundSMTPTransactionTimeout)); err != nil { + if err = tlsConn.SetDeadline(smtpDeadline(c.ctx, outboundSMTPTransactionTimeout)); err != nil { return err } c.conn = tlsConn @@ -593,6 +645,10 @@ func finishDelivery(c *Client, w io.WriteCloser, msg []byte) error { } func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { + return SendMailWithTLSContext(context.Background(), domain, addr, a, from, fromDomain, to, msg) +} + +func SendMailWithTLSContext(ctx context.Context, domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { if err := validateLine(from); err != nil { return err } @@ -601,7 +657,7 @@ func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, fromD return err } } - c, err := DialTls(addr, domain, fromDomain) + c, err := dialTLSContext(ctx, addr, domain, fromDomain, true) if err != nil { return err } @@ -654,6 +710,10 @@ func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, fromD // library. // 修复TSL验证问题 func SendMail(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { + return SendMailContext(context.Background(), domain, addr, a, from, fromDomain, to, msg) +} + +func SendMailContext(ctx context.Context, domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { log.Debugf("SMTP delivery started: recipient_count=%d", len(to)) @@ -665,7 +725,7 @@ func SendMail(domain string, addr string, a smtp.Auth, from string, fromDomain s return err } } - c, err := Dial(addr, fromDomain) + c, err := dialContext(ctx, addr, fromDomain, true) if err != nil { return err } @@ -713,6 +773,10 @@ func SendMail(domain string, addr string, a smtp.Auth, from string, fromDomain s // SendMailUnsafe 无TLS加密的邮件发送方式 func SendMailUnsafe(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { + return SendMailUnsafeContext(context.Background(), domain, addr, a, from, fromDomain, to, msg) +} + +func SendMailUnsafeContext(ctx context.Context, domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error { if err := validateUnsafePlaintextTarget(domain, addr); err != nil { return err } @@ -724,7 +788,7 @@ func SendMailUnsafe(domain string, addr string, a smtp.Auth, from string, fromDo return err } } - c, err := dial(addr, fromDomain, false) + c, err := dialContext(ctx, addr, fromDomain, false) if err != nil { return err }