From 98b1d9a9e9b74b3d24c4c37794c1a3d673615ea7 Mon Sep 17 00:00:00 2001 From: "Zerek.Cheng" <16066557+zerek-cheng@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:08:11 +0800 Subject: [PATCH 1/2] feat(mail): Allow SMTP to skip tls certificate validation --- .../internal/handler/admin/setting_handler.go | 201 ++++++++++-------- backend/internal/handler/dto/settings.go | 1 + backend/internal/server/api_contract_test.go | 16 +- backend/internal/service/domain_constants.go | 15 +- backend/internal/service/email_service.go | 52 +++-- backend/internal/service/setting_service.go | 3 + .../service/setting_service_update_test.go | 13 ++ backend/internal/service/settings_view.go | 1 + .../108_add_smtp_skip_tls_verify_setting.sql | 6 + frontend/src/api/admin/settings.ts | 4 + frontend/src/i18n/locales/en.ts | 4 +- frontend/src/i18n/locales/zh.ts | 4 +- frontend/src/views/admin/SettingsView.vue | 23 +- 13 files changed, 215 insertions(+), 128 deletions(-) create mode 100644 backend/migrations/108_add_smtp_skip_tls_verify_setting.sql diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index bec0f12613..f790399e53 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -110,6 +110,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { SMTPFrom: settings.SMTPFrom, SMTPFromName: settings.SMTPFromName, SMTPUseTLS: settings.SMTPUseTLS, + SMTPSkipTLSVerify: settings.SMTPSkipTLSVerify, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, @@ -216,13 +217,14 @@ type UpdateSettingsRequest struct { TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 // 邮件服务设置 - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPFrom string `json:"smtp_from_email"` - SMTPFromName string `json:"smtp_from_name"` - SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPSkipTLSVerify *bool `json:"smtp_skip_tls_verify"` // Cloudflare Turnstile 设置 TurnstileEnabled bool `json:"turnstile_enabled"` @@ -391,6 +393,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SMTPFrom = previousSettings.SMTPFrom req.SMTPFromName = previousSettings.SMTPFromName req.SMTPUseTLS = previousSettings.SMTPUseTLS + req.SMTPSkipTLSVerify = &previousSettings.SMTPSkipTLSVerify } // Turnstile 参数验证 @@ -798,63 +801,69 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { SMTPFrom: req.SMTPFrom, SMTPFromName: req.SMTPFromName, SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, - LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, - LinuxDoConnectClientID: req.LinuxDoConnectClientID, - LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, - LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, - OIDCConnectEnabled: req.OIDCConnectEnabled, - OIDCConnectProviderName: req.OIDCConnectProviderName, - OIDCConnectClientID: req.OIDCConnectClientID, - OIDCConnectClientSecret: req.OIDCConnectClientSecret, - OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: req.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, - OIDCConnectScopes: req.OIDCConnectScopes, - OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: req.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken, - OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, - SiteName: req.SiteName, - SiteLogo: req.SiteLogo, - SiteSubtitle: req.SiteSubtitle, - APIBaseURL: req.APIBaseURL, - ContactInfo: req.ContactInfo, - DocURL: req.DocURL, - HomeContent: req.HomeContent, - HideCcsImportButton: req.HideCcsImportButton, - PurchaseSubscriptionEnabled: purchaseEnabled, - PurchaseSubscriptionURL: purchaseURL, - TableDefaultPageSize: req.TableDefaultPageSize, - TablePageSizeOptions: req.TablePageSizeOptions, - CustomMenuItems: customMenuJSON, - CustomEndpoints: customEndpointsJSON, - DefaultConcurrency: req.DefaultConcurrency, - DefaultBalance: req.DefaultBalance, - DefaultSubscriptions: defaultSubscriptions, - EnableModelFallback: req.EnableModelFallback, - FallbackModelAnthropic: req.FallbackModelAnthropic, - FallbackModelOpenAI: req.FallbackModelOpenAI, - FallbackModelGemini: req.FallbackModelGemini, - FallbackModelAntigravity: req.FallbackModelAntigravity, - EnableIdentityPatch: req.EnableIdentityPatch, - IdentityPatchPrompt: req.IdentityPatchPrompt, - MinClaudeCodeVersion: req.MinClaudeCodeVersion, - MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, - BackendModeEnabled: req.BackendModeEnabled, + SMTPSkipTLSVerify: func() bool { + if req.SMTPSkipTLSVerify != nil { + return *req.SMTPSkipTLSVerify + } + return previousSettings.SMTPSkipTLSVerify + }(), + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, + LinuxDoConnectClientID: req.LinuxDoConnectClientID, + LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, + LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: req.OIDCConnectEnabled, + OIDCConnectProviderName: req.OIDCConnectProviderName, + OIDCConnectClientID: req.OIDCConnectClientID, + OIDCConnectClientSecret: req.OIDCConnectClientSecret, + OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: req.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, + OIDCConnectScopes: req.OIDCConnectScopes, + OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: req.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, + SiteName: req.SiteName, + SiteLogo: req.SiteLogo, + SiteSubtitle: req.SiteSubtitle, + APIBaseURL: req.APIBaseURL, + ContactInfo: req.ContactInfo, + DocURL: req.DocURL, + HomeContent: req.HomeContent, + HideCcsImportButton: req.HideCcsImportButton, + PurchaseSubscriptionEnabled: purchaseEnabled, + PurchaseSubscriptionURL: purchaseURL, + TableDefaultPageSize: req.TableDefaultPageSize, + TablePageSizeOptions: req.TablePageSizeOptions, + CustomMenuItems: customMenuJSON, + CustomEndpoints: customEndpointsJSON, + DefaultConcurrency: req.DefaultConcurrency, + DefaultBalance: req.DefaultBalance, + DefaultSubscriptions: defaultSubscriptions, + EnableModelFallback: req.EnableModelFallback, + FallbackModelAnthropic: req.FallbackModelAnthropic, + FallbackModelOpenAI: req.FallbackModelOpenAI, + FallbackModelGemini: req.FallbackModelGemini, + FallbackModelAntigravity: req.FallbackModelAntigravity, + EnableIdentityPatch: req.EnableIdentityPatch, + IdentityPatchPrompt: req.IdentityPatchPrompt, + MinClaudeCodeVersion: req.MinClaudeCodeVersion, + MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, + BackendModeEnabled: req.BackendModeEnabled, OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -1011,6 +1020,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { SMTPFrom: updatedSettings.SMTPFrom, SMTPFromName: updatedSettings.SMTPFromName, SMTPUseTLS: updatedSettings.SMTPUseTLS, + SMTPSkipTLSVerify: updatedSettings.SMTPSkipTLSVerify, TurnstileEnabled: updatedSettings.TurnstileEnabled, TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, @@ -1184,6 +1194,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.SMTPUseTLS != after.SMTPUseTLS { changed = append(changed, "smtp_use_tls") } + if before.SMTPSkipTLSVerify != after.SMTPSkipTLSVerify { + changed = append(changed, "smtp_skip_tls_verify") + } if before.TurnstileEnabled != after.TurnstileEnabled { changed = append(changed, "turnstile_enabled") } @@ -1462,11 +1475,12 @@ func equalNotifyEmailEntries(a, b []service.NotifyEmailEntry) bool { // TestSMTPRequest 测试SMTP连接请求 type TestSMTPRequest struct { - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPSkipTLSVerify *bool `json:"smtp_skip_tls_verify"` } // TestSMTPConnection 测试SMTP连接 @@ -1499,6 +1513,9 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { if req.SMTPUsername == "" && savedConfig != nil { req.SMTPUsername = savedConfig.Username } + if req.SMTPSkipTLSVerify == nil && savedConfig != nil { + req.SMTPSkipTLSVerify = &savedConfig.SkipTLSVerify + } password := strings.TrimSpace(req.SMTPPassword) if password == "" && savedConfig != nil { password = savedConfig.Password @@ -1509,11 +1526,12 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { } config := &service.SMTPConfig{ - Host: req.SMTPHost, - Port: req.SMTPPort, - Username: req.SMTPUsername, - Password: password, - UseTLS: req.SMTPUseTLS, + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, + Password: password, + UseTLS: req.SMTPUseTLS, + SkipTLSVerify: req.SMTPSkipTLSVerify != nil && *req.SMTPSkipTLSVerify, } err := h.emailService.TestSMTPConnectionWithConfig(config) @@ -1527,14 +1545,15 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { // SendTestEmailRequest 发送测试邮件请求 type SendTestEmailRequest struct { - Email string `json:"email" binding:"required,email"` - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPFrom string `json:"smtp_from_email"` - SMTPFromName string `json:"smtp_from_name"` - SMTPUseTLS bool `json:"smtp_use_tls"` + Email string `json:"email" binding:"required,email"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPSkipTLSVerify *bool `json:"smtp_skip_tls_verify"` } // SendTestEmail 发送测试邮件 @@ -1569,6 +1588,9 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { if req.SMTPUsername == "" && savedConfig != nil { req.SMTPUsername = savedConfig.Username } + if req.SMTPSkipTLSVerify == nil && savedConfig != nil { + req.SMTPSkipTLSVerify = &savedConfig.SkipTLSVerify + } password := strings.TrimSpace(req.SMTPPassword) if password == "" && savedConfig != nil { password = savedConfig.Password @@ -1585,13 +1607,14 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { } config := &service.SMTPConfig{ - Host: req.SMTPHost, - Port: req.SMTPPort, - Username: req.SMTPUsername, - Password: password, - From: req.SMTPFrom, - FromName: req.SMTPFromName, - UseTLS: req.SMTPUseTLS, + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, + Password: password, + From: req.SMTPFrom, + FromName: req.SMTPFromName, + UseTLS: req.SMTPUseTLS, + SkipTLSVerify: req.SMTPSkipTLSVerify != nil && *req.SMTPSkipTLSVerify, } siteName := h.settingService.GetSiteName(c.Request.Context()) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 3659e79be3..170863770f 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -41,6 +41,7 @@ type SystemSettings struct { SMTPFrom string `json:"smtp_from_email"` SMTPFromName string `json:"smtp_from_name"` SMTPUseTLS bool `json:"smtp_use_tls"` + SMTPSkipTLSVerify bool `json:"smtp_skip_tls_verify"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index b686b986fa..6d6a62ae86 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -454,13 +454,14 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyRegistrationEmailSuffixWhitelist: "[]", service.SettingKeyPromoCodeEnabled: "true", - service.SettingKeySMTPHost: "smtp.example.com", - service.SettingKeySMTPPort: "587", - service.SettingKeySMTPUsername: "user", - service.SettingKeySMTPPassword: "secret", - service.SettingKeySMTPFrom: "no-reply@example.com", - service.SettingKeySMTPFromName: "Sub2API", - service.SettingKeySMTPUseTLS: "true", + service.SettingKeySMTPHost: "smtp.example.com", + service.SettingKeySMTPPort: "587", + service.SettingKeySMTPUsername: "user", + service.SettingKeySMTPPassword: "secret", + service.SettingKeySMTPFrom: "no-reply@example.com", + service.SettingKeySMTPFromName: "Sub2API", + service.SettingKeySMTPUseTLS: "true", + service.SettingKeySMTPSkipTLSVerify: "false", service.SettingKeyTurnstileEnabled: "true", service.SettingKeyTurnstileSiteKey: "site-key", @@ -528,6 +529,7 @@ func TestAPIContracts(t *testing.T) { "smtp_from_email": "no-reply@example.com", "smtp_from_name": "Sub2API", "smtp_use_tls": true, + "smtp_skip_tls_verify": false, "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index cb452efbee..2b1d04f069 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -86,13 +86,14 @@ const ( SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 // 邮件服务设置 - SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 - SettingKeySMTPPort = "smtp_port" // SMTP端口 - SettingKeySMTPUsername = "smtp_username" // SMTP用户名 - SettingKeySMTPPassword = "smtp_password" // SMTP密码(加密存储) - SettingKeySMTPFrom = "smtp_from" // 发件人地址 - SettingKeySMTPFromName = "smtp_from_name" // 发件人名称 - SettingKeySMTPUseTLS = "smtp_use_tls" // 是否使用TLS + SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 + SettingKeySMTPPort = "smtp_port" // SMTP端口 + SettingKeySMTPUsername = "smtp_username" // SMTP用户名 + SettingKeySMTPPassword = "smtp_password" // SMTP密码(加密存储) + SettingKeySMTPFrom = "smtp_from" // 发件人地址 + SettingKeySMTPFromName = "smtp_from_name" // 发件人名称 + SettingKeySMTPUseTLS = "smtp_use_tls" // 是否使用TLS + SettingKeySMTPSkipTLSVerify = "smtp_skip_tls_verify" // 是否跳过 TLS 证书信任校验 // Cloudflare Turnstile 设置 SettingKeyTurnstileEnabled = "turnstile_enabled" // 是否启用 Turnstile 验证 diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 9a03ea30d4..c52e5eb77c 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -83,13 +83,14 @@ const ( // SMTPConfig SMTP配置 type SMTPConfig struct { - Host string - Port int - Username string - Password string - From string - FromName string - UseTLS bool + Host string + Port int + Username string + Password string + From string + FromName string + UseTLS bool + SkipTLSVerify bool } // EmailService 邮件服务 @@ -116,6 +117,7 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { SettingKeySMTPFrom, SettingKeySMTPFromName, SettingKeySMTPUseTLS, + SettingKeySMTPSkipTLSVerify, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -136,15 +138,17 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { } useTLS := settings[SettingKeySMTPUseTLS] == "true" + skipTLSVerify := settings[SettingKeySMTPSkipTLSVerify] == "true" return &SMTPConfig{ - Host: host, - Port: port, - Username: strings.TrimSpace(settings[SettingKeySMTPUsername]), - Password: strings.TrimSpace(settings[SettingKeySMTPPassword]), - From: strings.TrimSpace(settings[SettingKeySMTPFrom]), - FromName: strings.TrimSpace(settings[SettingKeySMTPFromName]), - UseTLS: useTLS, + Host: host, + Port: port, + Username: strings.TrimSpace(settings[SettingKeySMTPUsername]), + Password: strings.TrimSpace(settings[SettingKeySMTPPassword]), + From: strings.TrimSpace(settings[SettingKeySMTPFrom]), + FromName: strings.TrimSpace(settings[SettingKeySMTPFromName]), + UseTLS: useTLS, + SkipTLSVerify: skipTLSVerify, }, nil } @@ -178,14 +182,14 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if config.UseTLS { - return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host) + return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host, config.SkipTLSVerify) } - return s.sendMailPlain(addr, auth, config.From, to, []byte(msg), config.Host) + return s.sendMailPlain(addr, auth, config.From, to, []byte(msg), config.Host, config.SkipTLSVerify) } // sendMailPlain sends mail without TLS using a dialer with timeout. -func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { +func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to string, msg []byte, host string, skipTLSVerify bool) error { dialer := &net.Dialer{Timeout: smtpDialTimeout} conn, err := dialer.Dial("tcp", addr) if err != nil { @@ -203,7 +207,11 @@ func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to strin // Opportunistic STARTTLS: upgrade to encrypted connection if the server supports it. // This mirrors the behavior of smtp.SendMail which we replaced for timeout support. if ok, _ := client.Extension("STARTTLS"); ok { - if err = client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { + if err = client.StartTLS(&tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: skipTLSVerify, + }); err != nil { return fmt.Errorf("starttls: %w", err) } } @@ -232,9 +240,10 @@ func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to strin } // sendMailTLS 使用TLS发送邮件 -func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { +func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string, skipTLSVerify bool) error { tlsConfig := &tls.Config{ - ServerName: host, + ServerName: host, + InsecureSkipVerify: skipTLSVerify, // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。 MinVersion: tls.VersionTLS12, } @@ -420,7 +429,8 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { if config.UseTLS { tlsConfig := &tls.Config{ - ServerName: config.Host, + ServerName: config.Host, + InsecureSkipVerify: config.SkipTLSVerify, // 与发送逻辑一致,显式要求 TLS 1.2+。 MinVersion: tls.VersionTLS12, } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7f4a2eb131..b61f59e083 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -518,6 +518,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeySMTPFrom] = settings.SMTPFrom updates[SettingKeySMTPFromName] = settings.SMTPFromName updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) + updates[SettingKeySMTPSkipTLSVerify] = strconv.FormatBool(settings.SMTPSkipTLSVerify) // Cloudflare Turnstile 设置(只有非空才更新密钥) updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) @@ -952,6 +953,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyDefaultSubscriptions: "[]", SettingKeySMTPPort: "587", SettingKeySMTPUseTLS: "false", + SettingKeySMTPSkipTLSVerify: "false", // Model fallback defaults SettingKeyEnableModelFallback: "false", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", @@ -996,6 +998,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin SMTPFrom: settings[SettingKeySMTPFrom], SMTPFromName: settings[SettingKeySMTPFromName], SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", + SMTPSkipTLSVerify: settings[SettingKeySMTPSkipTLSVerify] == "true", SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index e62218b454..f4d1046b39 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -223,3 +223,16 @@ func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { require.Equal(t, "1000", repo.updates[SettingKeyTableDefaultPageSize]) require.Equal(t, "[20,100]", repo.updates[SettingKeyTablePageSizeOptions]) } + +func TestSettingService_UpdateSettings_SMTPFlags(t *testing.T) { + repo := &settingUpdateRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + SMTPUseTLS: true, + SMTPSkipTLSVerify: true, + }) + require.NoError(t, err) + require.Equal(t, "true", repo.updates[SettingKeySMTPUseTLS]) + require.Equal(t, "true", repo.updates[SettingKeySMTPSkipTLSVerify]) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ab2eb274fd..b3c8f81a9f 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -18,6 +18,7 @@ type SystemSettings struct { SMTPFrom string SMTPFromName string SMTPUseTLS bool + SMTPSkipTLSVerify bool TurnstileEnabled bool TurnstileSiteKey string diff --git a/backend/migrations/108_add_smtp_skip_tls_verify_setting.sql b/backend/migrations/108_add_smtp_skip_tls_verify_setting.sql new file mode 100644 index 0000000000..af8cb7cf80 --- /dev/null +++ b/backend/migrations/108_add_smtp_skip_tls_verify_setting.sql @@ -0,0 +1,6 @@ +-- Add smtp_skip_tls_verify setting with a safe default for existing deployments. +-- Default remains false, meaning SMTP TLS certificates are validated. + +INSERT INTO settings (key, value) +VALUES ('smtp_skip_tls_verify', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 1e4a305309..9f4545682f 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -51,6 +51,7 @@ export interface SystemSettings { smtp_from_email: string smtp_from_name: string smtp_use_tls: boolean + smtp_skip_tls_verify: boolean // Cloudflare Turnstile settings turnstile_enabled: boolean turnstile_site_key: string @@ -178,6 +179,7 @@ export interface UpdateSettingsRequest { smtp_from_email?: string smtp_from_name?: string smtp_use_tls?: boolean + smtp_skip_tls_verify?: boolean turnstile_enabled?: boolean turnstile_site_key?: string turnstile_secret_key?: string @@ -281,6 +283,7 @@ export interface TestSmtpRequest { smtp_username: string smtp_password: string smtp_use_tls: boolean + smtp_skip_tls_verify: boolean } /** @@ -305,6 +308,7 @@ export interface SendTestEmailRequest { smtp_from_email: string smtp_from_name: string smtp_use_tls: boolean + smtp_skip_tls_verify: boolean } /** diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d1def45c31..f07d26d07e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4700,7 +4700,9 @@ export default { fromName: 'From Name', fromNamePlaceholder: 'Sub2API', useTls: 'Use TLS', - useTlsHint: 'Enable TLS encryption for SMTP connection' + useTlsHint: 'Enable TLS encryption for SMTP connection', + skipTlsVerify: 'Skip TLS certificate verification', + skipTlsVerifyHint: 'Enable only when the SMTP server uses a self-signed certificate or an incomplete chain' }, testEmail: { title: 'Send Test Email', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 6f57ab3ea4..23c5fb9ec6 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4864,7 +4864,9 @@ export default { fromName: '发件人名称', fromNamePlaceholder: 'Sub2API', useTls: '使用 TLS', - useTlsHint: '为 SMTP 连接启用 TLS 加密' + useTlsHint: '为 SMTP 连接启用 TLS 加密', + skipTlsVerify: '跳过 TLS 证书信任校验', + skipTlsVerifyHint: '仅在 SMTP 服务器使用自签名证书或证书链不完整时启用' }, testEmail: { title: '发送测试邮件', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index ee6a4c6d41..c8bbd3b3b9 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2647,6 +2647,21 @@ +
+
+ +

+ {{ t('admin.settings.smtp.skipTlsVerifyHint') }} +

+
+ +
+ @@ -2997,6 +3012,7 @@ const form = reactive({ smtp_from_email: '', smtp_from_name: '', smtp_use_tls: true, + smtp_skip_tls_verify: false, // Cloudflare Turnstile turnstile_enabled: false, turnstile_site_key: '', @@ -3592,6 +3608,7 @@ async function saveSettings() { smtp_from_email: form.smtp_from_email, smtp_from_name: form.smtp_from_name, smtp_use_tls: form.smtp_use_tls, + smtp_skip_tls_verify: form.smtp_skip_tls_verify, turnstile_enabled: form.turnstile_enabled, turnstile_site_key: form.turnstile_site_key, turnstile_secret_key: form.turnstile_secret_key || undefined, @@ -3705,7 +3722,8 @@ async function testSmtpConnection() { smtp_port: form.smtp_port, smtp_username: form.smtp_username, smtp_password: smtpPasswordForTest, - smtp_use_tls: form.smtp_use_tls + smtp_use_tls: form.smtp_use_tls, + smtp_skip_tls_verify: form.smtp_skip_tls_verify }) // API returns { message: "..." } on success, errors are thrown as exceptions appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess')) @@ -3733,7 +3751,8 @@ async function sendTestEmail() { smtp_password: smtpPasswordForSend, smtp_from_email: form.smtp_from_email, smtp_from_name: form.smtp_from_name, - smtp_use_tls: form.smtp_use_tls + smtp_use_tls: form.smtp_use_tls, + smtp_skip_tls_verify: form.smtp_skip_tls_verify }) // API returns { message: "..." } on success, errors are thrown as exceptions appStore.showSuccess(result.message || t('admin.settings.testEmailSent')) From 539f92f128da6d4f868acd645efa255505f22be0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:18:32 +0000 Subject: [PATCH 2/2] chore: sync VERSION to 0.1.114-2 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index c29f5f750e..09904489bf 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.114 +0.1.114-2