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') }} +
+