Add Resend email provider and default login redirect
Browse files- backend/internal/handler/admin/setting_handler.go +188 -4
- backend/internal/handler/dto/settings.go +4 -0
- backend/internal/server/routes/admin.go +2 -0
- backend/internal/service/domain_constants.go +4 -0
- backend/internal/service/email_service.go +159 -5
- backend/internal/service/email_service_resend_test.go +153 -0
- backend/internal/service/setting_service.go +22 -1
- backend/internal/service/setting_service_update_test.go +35 -0
- backend/internal/service/settings_view.go +5 -0
- frontend/src/api/admin/settings.ts +36 -0
- frontend/src/i18n/locales/en.ts +24 -0
- frontend/src/i18n/locales/zh.ts +24 -0
- frontend/src/router/__tests__/routes.spec.ts +69 -0
- frontend/src/router/index.ts +1 -1
- frontend/src/views/admin/SettingsView.vue +165 -13
backend/internal/handler/admin/setting_handler.go
CHANGED
|
@@ -91,6 +91,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|
| 91 |
SMTPFrom: settings.SMTPFrom,
|
| 92 |
SMTPFromName: settings.SMTPFromName,
|
| 93 |
SMTPUseTLS: settings.SMTPUseTLS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
TurnstileEnabled: settings.TurnstileEnabled,
|
| 95 |
TurnstileSiteKey: settings.TurnstileSiteKey,
|
| 96 |
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
|
@@ -151,6 +155,10 @@ type UpdateSettingsRequest struct {
|
|
| 151 |
SMTPFrom string `json:"smtp_from_email"`
|
| 152 |
SMTPFromName string `json:"smtp_from_name"`
|
| 153 |
SMTPUseTLS bool `json:"smtp_use_tls"`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
// Cloudflare Turnstile 设置
|
| 156 |
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
@@ -234,6 +242,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|
| 234 |
if req.SMTPPort <= 0 {
|
| 235 |
req.SMTPPort = 587
|
| 236 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions)
|
| 238 |
|
| 239 |
// Turnstile 参数验证
|
|
@@ -476,6 +493,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|
| 476 |
SMTPFrom: req.SMTPFrom,
|
| 477 |
SMTPFromName: req.SMTPFromName,
|
| 478 |
SMTPUseTLS: req.SMTPUseTLS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
TurnstileEnabled: req.TurnstileEnabled,
|
| 480 |
TurnstileSiteKey: req.TurnstileSiteKey,
|
| 481 |
TurnstileSecretKey: req.TurnstileSecretKey,
|
|
@@ -573,6 +594,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|
| 573 |
SMTPFrom: updatedSettings.SMTPFrom,
|
| 574 |
SMTPFromName: updatedSettings.SMTPFromName,
|
| 575 |
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
| 577 |
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
| 578 |
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
|
@@ -674,6 +699,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|
| 674 |
if before.SMTPUseTLS != after.SMTPUseTLS {
|
| 675 |
changed = append(changed, "smtp_use_tls")
|
| 676 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
if before.TurnstileEnabled != after.TurnstileEnabled {
|
| 678 |
changed = append(changed, "turnstile_enabled")
|
| 679 |
}
|
|
@@ -874,6 +911,37 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
|
|
| 874 |
response.Success(c, gin.H{"message": "SMTP connection successful"})
|
| 875 |
}
|
| 876 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
// SendTestEmailRequest 发送测试邮件请求
|
| 878 |
type SendTestEmailRequest struct {
|
| 879 |
Email string `json:"email" binding:"required,email"`
|
|
@@ -901,20 +969,38 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
|
| 901 |
|
| 902 |
// 如果未提供密码,从数据库获取已保存的密码
|
| 903 |
password := req.SMTPPassword
|
|
|
|
| 904 |
if password == "" {
|
| 905 |
-
|
|
|
|
| 906 |
if err == nil && savedConfig != nil {
|
| 907 |
password = savedConfig.Password
|
| 908 |
}
|
| 909 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
|
| 911 |
config := &service.SMTPConfig{
|
| 912 |
Host: req.SMTPHost,
|
| 913 |
Port: req.SMTPPort,
|
| 914 |
Username: req.SMTPUsername,
|
| 915 |
Password: password,
|
| 916 |
-
From:
|
| 917 |
-
FromName:
|
| 918 |
UseTLS: req.SMTPUseTLS,
|
| 919 |
}
|
| 920 |
|
|
@@ -930,7 +1016,8 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
|
| 930 |
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
| 931 |
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
| 932 |
.content { padding: 40px 30px; text-align: center; }
|
| 933 |
-
.success {
|
|
|
|
| 934 |
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
| 935 |
</style>
|
| 936 |
</head>
|
|
@@ -940,7 +1027,10 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
|
| 940 |
<h1>` + siteName + `</h1>
|
| 941 |
</div>
|
| 942 |
<div class="content">
|
|
|
|
| 943 |
<div class="success">✓</div>
|
|
|
|
|
|
|
| 944 |
<h2>Email Configuration Successful!</h2>
|
| 945 |
<p>This is a test email to verify your SMTP settings are working correctly.</p>
|
| 946 |
</div>
|
|
@@ -960,6 +1050,100 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
|
| 960 |
response.Success(c, gin.H{"message": "Test email sent successfully"})
|
| 961 |
}
|
| 962 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
// GetAdminAPIKey 获取管理员 API Key 状态
|
| 964 |
// GET /api/v1/admin/settings/admin-api-key
|
| 965 |
func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) {
|
|
|
|
| 91 |
SMTPFrom: settings.SMTPFrom,
|
| 92 |
SMTPFromName: settings.SMTPFromName,
|
| 93 |
SMTPUseTLS: settings.SMTPUseTLS,
|
| 94 |
+
EmailProvider: settings.EmailProvider,
|
| 95 |
+
ResendAPIKeyConfigured: settings.ResendAPIKeyConfigured,
|
| 96 |
+
ResendFrom: settings.ResendFrom,
|
| 97 |
+
ResendFromName: settings.ResendFromName,
|
| 98 |
TurnstileEnabled: settings.TurnstileEnabled,
|
| 99 |
TurnstileSiteKey: settings.TurnstileSiteKey,
|
| 100 |
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
|
|
|
| 155 |
SMTPFrom string `json:"smtp_from_email"`
|
| 156 |
SMTPFromName string `json:"smtp_from_name"`
|
| 157 |
SMTPUseTLS bool `json:"smtp_use_tls"`
|
| 158 |
+
EmailProvider string `json:"email_provider" binding:"omitempty,oneof=smtp resend"`
|
| 159 |
+
ResendAPIKey string `json:"resend_api_key"`
|
| 160 |
+
ResendFrom string `json:"resend_from_email"`
|
| 161 |
+
ResendFromName string `json:"resend_from_name"`
|
| 162 |
|
| 163 |
// Cloudflare Turnstile 设置
|
| 164 |
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
|
|
| 242 |
if req.SMTPPort <= 0 {
|
| 243 |
req.SMTPPort = 587
|
| 244 |
}
|
| 245 |
+
req.EmailProvider = strings.TrimSpace(req.EmailProvider)
|
| 246 |
+
if req.EmailProvider == "" {
|
| 247 |
+
req.EmailProvider = previousSettings.EmailProvider
|
| 248 |
+
if req.EmailProvider == "" {
|
| 249 |
+
req.EmailProvider = service.EmailProviderSMTP
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
req.ResendFrom = strings.TrimSpace(req.ResendFrom)
|
| 253 |
+
req.ResendFromName = strings.TrimSpace(req.ResendFromName)
|
| 254 |
req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions)
|
| 255 |
|
| 256 |
// Turnstile 参数验证
|
|
|
|
| 493 |
SMTPFrom: req.SMTPFrom,
|
| 494 |
SMTPFromName: req.SMTPFromName,
|
| 495 |
SMTPUseTLS: req.SMTPUseTLS,
|
| 496 |
+
EmailProvider: req.EmailProvider,
|
| 497 |
+
ResendAPIKey: req.ResendAPIKey,
|
| 498 |
+
ResendFrom: req.ResendFrom,
|
| 499 |
+
ResendFromName: req.ResendFromName,
|
| 500 |
TurnstileEnabled: req.TurnstileEnabled,
|
| 501 |
TurnstileSiteKey: req.TurnstileSiteKey,
|
| 502 |
TurnstileSecretKey: req.TurnstileSecretKey,
|
|
|
|
| 594 |
SMTPFrom: updatedSettings.SMTPFrom,
|
| 595 |
SMTPFromName: updatedSettings.SMTPFromName,
|
| 596 |
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
| 597 |
+
EmailProvider: updatedSettings.EmailProvider,
|
| 598 |
+
ResendAPIKeyConfigured: updatedSettings.ResendAPIKeyConfigured,
|
| 599 |
+
ResendFrom: updatedSettings.ResendFrom,
|
| 600 |
+
ResendFromName: updatedSettings.ResendFromName,
|
| 601 |
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
| 602 |
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
| 603 |
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
|
|
|
| 699 |
if before.SMTPUseTLS != after.SMTPUseTLS {
|
| 700 |
changed = append(changed, "smtp_use_tls")
|
| 701 |
}
|
| 702 |
+
if before.EmailProvider != after.EmailProvider {
|
| 703 |
+
changed = append(changed, "email_provider")
|
| 704 |
+
}
|
| 705 |
+
if req.ResendAPIKey != "" {
|
| 706 |
+
changed = append(changed, "resend_api_key")
|
| 707 |
+
}
|
| 708 |
+
if before.ResendFrom != after.ResendFrom {
|
| 709 |
+
changed = append(changed, "resend_from_email")
|
| 710 |
+
}
|
| 711 |
+
if before.ResendFromName != after.ResendFromName {
|
| 712 |
+
changed = append(changed, "resend_from_name")
|
| 713 |
+
}
|
| 714 |
if before.TurnstileEnabled != after.TurnstileEnabled {
|
| 715 |
changed = append(changed, "turnstile_enabled")
|
| 716 |
}
|
|
|
|
| 911 |
response.Success(c, gin.H{"message": "SMTP connection successful"})
|
| 912 |
}
|
| 913 |
|
| 914 |
+
type TestResendRequest struct {
|
| 915 |
+
ResendAPIKey string `json:"resend_api_key"`
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
// TestResendConnection tests Resend API connectivity.
|
| 919 |
+
// POST /api/v1/admin/settings/test-resend
|
| 920 |
+
func (h *SettingHandler) TestResendConnection(c *gin.Context) {
|
| 921 |
+
var req TestResendRequest
|
| 922 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 923 |
+
response.BadRequest(c, "Invalid request: "+err.Error())
|
| 924 |
+
return
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
apiKey := strings.TrimSpace(req.ResendAPIKey)
|
| 928 |
+
if apiKey == "" {
|
| 929 |
+
savedConfig, err := h.emailService.GetResendConfig(c.Request.Context())
|
| 930 |
+
if err == nil && savedConfig != nil {
|
| 931 |
+
apiKey = savedConfig.APIKey
|
| 932 |
+
}
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
if err := h.emailService.TestResendConnectionWithConfig(c.Request.Context(), &service.ResendConfig{
|
| 936 |
+
APIKey: apiKey,
|
| 937 |
+
}); err != nil {
|
| 938 |
+
response.BadRequest(c, "Resend connection test failed: "+err.Error())
|
| 939 |
+
return
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
response.Success(c, gin.H{"message": "Resend connection successful"})
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
// SendTestEmailRequest 发送测试邮件请求
|
| 946 |
type SendTestEmailRequest struct {
|
| 947 |
Email string `json:"email" binding:"required,email"`
|
|
|
|
| 969 |
|
| 970 |
// 如果未提供密码,从数据库获取已保存的密码
|
| 971 |
password := req.SMTPPassword
|
| 972 |
+
var savedConfig *service.SMTPConfig
|
| 973 |
if password == "" {
|
| 974 |
+
var err error
|
| 975 |
+
savedConfig, err = h.emailService.GetSMTPConfig(c.Request.Context())
|
| 976 |
if err == nil && savedConfig != nil {
|
| 977 |
password = savedConfig.Password
|
| 978 |
}
|
| 979 |
}
|
| 980 |
+
if savedConfig == nil && (strings.TrimSpace(req.SMTPFrom) == "" || strings.TrimSpace(req.SMTPFromName) == "") {
|
| 981 |
+
var err error
|
| 982 |
+
savedConfig, err = h.emailService.GetSMTPConfig(c.Request.Context())
|
| 983 |
+
if err != nil {
|
| 984 |
+
savedConfig = nil
|
| 985 |
+
}
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
from := strings.TrimSpace(req.SMTPFrom)
|
| 989 |
+
if from == "" && savedConfig != nil {
|
| 990 |
+
from = savedConfig.From
|
| 991 |
+
}
|
| 992 |
+
fromName := strings.TrimSpace(req.SMTPFromName)
|
| 993 |
+
if fromName == "" && savedConfig != nil {
|
| 994 |
+
fromName = savedConfig.FromName
|
| 995 |
+
}
|
| 996 |
|
| 997 |
config := &service.SMTPConfig{
|
| 998 |
Host: req.SMTPHost,
|
| 999 |
Port: req.SMTPPort,
|
| 1000 |
Username: req.SMTPUsername,
|
| 1001 |
Password: password,
|
| 1002 |
+
From: from,
|
| 1003 |
+
FromName: fromName,
|
| 1004 |
UseTLS: req.SMTPUseTLS,
|
| 1005 |
}
|
| 1006 |
|
|
|
|
| 1016 |
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
| 1017 |
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
| 1018 |
.content { padding: 40px 30px; text-align: center; }
|
| 1019 |
+
.success { display: none; }
|
| 1020 |
+
.success-ok { color: #10b981; font-size: 48px; margin-bottom: 20px; }
|
| 1021 |
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
| 1022 |
</style>
|
| 1023 |
</head>
|
|
|
|
| 1027 |
<h1>` + siteName + `</h1>
|
| 1028 |
</div>
|
| 1029 |
<div class="content">
|
| 1030 |
+
<!--
|
| 1031 |
<div class="success">✓</div>
|
| 1032 |
+
-->
|
| 1033 |
+
<div class="success-ok">OK</div>
|
| 1034 |
<h2>Email Configuration Successful!</h2>
|
| 1035 |
<p>This is a test email to verify your SMTP settings are working correctly.</p>
|
| 1036 |
</div>
|
|
|
|
| 1050 |
response.Success(c, gin.H{"message": "Test email sent successfully"})
|
| 1051 |
}
|
| 1052 |
|
| 1053 |
+
type SendResendTestEmailRequest struct {
|
| 1054 |
+
Email string `json:"email" binding:"required,email"`
|
| 1055 |
+
ResendAPIKey string `json:"resend_api_key"`
|
| 1056 |
+
ResendFrom string `json:"resend_from_email" binding:"omitempty,email"`
|
| 1057 |
+
ResendFromName string `json:"resend_from_name"`
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
// SendResendTestEmail sends a test email using Resend.
|
| 1061 |
+
// POST /api/v1/admin/settings/send-test-email-resend
|
| 1062 |
+
func (h *SettingHandler) SendResendTestEmail(c *gin.Context) {
|
| 1063 |
+
var req SendResendTestEmailRequest
|
| 1064 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 1065 |
+
response.BadRequest(c, "Invalid request: "+err.Error())
|
| 1066 |
+
return
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
apiKey := strings.TrimSpace(req.ResendAPIKey)
|
| 1070 |
+
var savedConfig *service.ResendConfig
|
| 1071 |
+
if apiKey == "" {
|
| 1072 |
+
var err error
|
| 1073 |
+
savedConfig, err = h.emailService.GetResendConfig(c.Request.Context())
|
| 1074 |
+
if err == nil && savedConfig != nil {
|
| 1075 |
+
apiKey = savedConfig.APIKey
|
| 1076 |
+
}
|
| 1077 |
+
}
|
| 1078 |
+
if savedConfig == nil && (strings.TrimSpace(req.ResendFrom) == "" || strings.TrimSpace(req.ResendFromName) == "") {
|
| 1079 |
+
var err error
|
| 1080 |
+
savedConfig, err = h.emailService.GetResendConfig(c.Request.Context())
|
| 1081 |
+
if err != nil {
|
| 1082 |
+
savedConfig = nil
|
| 1083 |
+
}
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
from := strings.TrimSpace(req.ResendFrom)
|
| 1087 |
+
if from == "" && savedConfig != nil {
|
| 1088 |
+
from = savedConfig.From
|
| 1089 |
+
}
|
| 1090 |
+
fromName := strings.TrimSpace(req.ResendFromName)
|
| 1091 |
+
if fromName == "" && savedConfig != nil {
|
| 1092 |
+
fromName = savedConfig.FromName
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
config := &service.ResendConfig{
|
| 1096 |
+
APIKey: apiKey,
|
| 1097 |
+
From: from,
|
| 1098 |
+
FromName: fromName,
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
siteName := h.settingService.GetSiteName(c.Request.Context())
|
| 1102 |
+
subject := "[" + siteName + "] Resend Test Email"
|
| 1103 |
+
body := `
|
| 1104 |
+
<!DOCTYPE html>
|
| 1105 |
+
<html>
|
| 1106 |
+
<head>
|
| 1107 |
+
<meta charset="UTF-8">
|
| 1108 |
+
<style>
|
| 1109 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
| 1110 |
+
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
| 1111 |
+
.header { background: linear-gradient(135deg, #0f172a 0%, #2563eb 100%); color: white; padding: 30px; text-align: center; }
|
| 1112 |
+
.content { padding: 40px 30px; text-align: center; }
|
| 1113 |
+
.success { display: none; }
|
| 1114 |
+
.success-ok { color: #10b981; font-size: 48px; margin-bottom: 20px; }
|
| 1115 |
+
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
| 1116 |
+
</style>
|
| 1117 |
+
</head>
|
| 1118 |
+
<body>
|
| 1119 |
+
<div class="container">
|
| 1120 |
+
<div class="header">
|
| 1121 |
+
<h1>` + siteName + `</h1>
|
| 1122 |
+
</div>
|
| 1123 |
+
<div class="content">
|
| 1124 |
+
<!--
|
| 1125 |
+
<div class="success">✓</div>
|
| 1126 |
+
-->
|
| 1127 |
+
<div class="success-ok">OK</div>
|
| 1128 |
+
<h2>Resend API Configuration Successful!</h2>
|
| 1129 |
+
<p>This is a test email sent through the Resend API.</p>
|
| 1130 |
+
</div>
|
| 1131 |
+
<div class="footer">
|
| 1132 |
+
<p>This is an automated test message.</p>
|
| 1133 |
+
</div>
|
| 1134 |
+
</div>
|
| 1135 |
+
</body>
|
| 1136 |
+
</html>
|
| 1137 |
+
`
|
| 1138 |
+
|
| 1139 |
+
if err := h.emailService.SendEmailWithResendConfig(c.Request.Context(), config, req.Email, subject, body); err != nil {
|
| 1140 |
+
response.BadRequest(c, "Failed to send Resend test email: "+err.Error())
|
| 1141 |
+
return
|
| 1142 |
+
}
|
| 1143 |
+
|
| 1144 |
+
response.Success(c, gin.H{"message": "Resend test email sent successfully"})
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
// GetAdminAPIKey 获取管理员 API Key 状态
|
| 1148 |
// GET /api/v1/admin/settings/admin-api-key
|
| 1149 |
func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) {
|
backend/internal/handler/dto/settings.go
CHANGED
|
@@ -34,6 +34,10 @@ type SystemSettings struct {
|
|
| 34 |
SMTPFrom string `json:"smtp_from_email"`
|
| 35 |
SMTPFromName string `json:"smtp_from_name"`
|
| 36 |
SMTPUseTLS bool `json:"smtp_use_tls"`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
TurnstileEnabled bool `json:"turnstile_enabled"`
|
| 39 |
TurnstileSiteKey string `json:"turnstile_site_key"`
|
|
|
|
| 34 |
SMTPFrom string `json:"smtp_from_email"`
|
| 35 |
SMTPFromName string `json:"smtp_from_name"`
|
| 36 |
SMTPUseTLS bool `json:"smtp_use_tls"`
|
| 37 |
+
EmailProvider string `json:"email_provider"`
|
| 38 |
+
ResendAPIKeyConfigured bool `json:"resend_api_key_configured"`
|
| 39 |
+
ResendFrom string `json:"resend_from_email"`
|
| 40 |
+
ResendFromName string `json:"resend_from_name"`
|
| 41 |
|
| 42 |
TurnstileEnabled bool `json:"turnstile_enabled"`
|
| 43 |
TurnstileSiteKey string `json:"turnstile_site_key"`
|
backend/internal/server/routes/admin.go
CHANGED
|
@@ -398,7 +398,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|
| 398 |
adminSettings.GET("", h.Admin.Setting.GetSettings)
|
| 399 |
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
| 400 |
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
|
|
|
| 401 |
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
|
|
|
|
| 402 |
// Admin API Key 管理
|
| 403 |
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
| 404 |
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
|
|
|
| 398 |
adminSettings.GET("", h.Admin.Setting.GetSettings)
|
| 399 |
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
| 400 |
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
| 401 |
+
adminSettings.POST("/test-resend", h.Admin.Setting.TestResendConnection)
|
| 402 |
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
|
| 403 |
+
adminSettings.POST("/send-test-email-resend", h.Admin.Setting.SendResendTestEmail)
|
| 404 |
// Admin API Key 管理
|
| 405 |
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
| 406 |
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
backend/internal/service/domain_constants.go
CHANGED
|
@@ -91,6 +91,10 @@ const (
|
|
| 91 |
SettingKeySMTPFrom = "smtp_from" // 发件人地址
|
| 92 |
SettingKeySMTPFromName = "smtp_from_name" // 发件人名称
|
| 93 |
SettingKeySMTPUseTLS = "smtp_use_tls" // 是否使用TLS
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
// Cloudflare Turnstile 设置
|
| 96 |
SettingKeyTurnstileEnabled = "turnstile_enabled" // 是否启用 Turnstile 验证
|
|
|
|
| 91 |
SettingKeySMTPFrom = "smtp_from" // 发件人地址
|
| 92 |
SettingKeySMTPFromName = "smtp_from_name" // 发件人名称
|
| 93 |
SettingKeySMTPUseTLS = "smtp_use_tls" // 是否使用TLS
|
| 94 |
+
SettingKeyEmailProvider = "email_provider" // 邮件发送方式: smtp | resend
|
| 95 |
+
SettingKeyResendAPIKey = "resend_api_key" // Resend API Key
|
| 96 |
+
SettingKeyResendFrom = "resend_from" // Resend 发件人地址
|
| 97 |
+
SettingKeyResendFromName = "resend_from_name" // Resend 发件人名称
|
| 98 |
|
| 99 |
// Cloudflare Turnstile 设置
|
| 100 |
SettingKeyTurnstileEnabled = "turnstile_enabled" // 是否启用 Turnstile 验证
|
backend/internal/service/email_service.go
CHANGED
|
@@ -1,17 +1,23 @@
|
|
| 1 |
package service
|
| 2 |
|
| 3 |
import (
|
|
|
|
| 4 |
"context"
|
| 5 |
"crypto/rand"
|
| 6 |
"crypto/subtle"
|
| 7 |
"crypto/tls"
|
| 8 |
"encoding/hex"
|
|
|
|
|
|
|
| 9 |
"fmt"
|
|
|
|
| 10 |
"log"
|
| 11 |
"math/big"
|
|
|
|
| 12 |
"net/smtp"
|
| 13 |
"net/url"
|
| 14 |
"strconv"
|
|
|
|
| 15 |
"time"
|
| 16 |
|
| 17 |
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
@@ -67,6 +73,11 @@ const (
|
|
| 67 |
|
| 68 |
// Password reset email cooldown (prevent email bombing)
|
| 69 |
passwordResetEmailCooldown = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
)
|
| 71 |
|
| 72 |
// SMTPConfig SMTP配置
|
|
@@ -80,17 +91,27 @@ type SMTPConfig struct {
|
|
| 80 |
UseTLS bool
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
// EmailService 邮件服务
|
| 84 |
type EmailService struct {
|
| 85 |
-
settingRepo
|
| 86 |
-
cache
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
// NewEmailService 创建邮件服务实例
|
| 90 |
func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailService {
|
| 91 |
return &EmailService{
|
| 92 |
-
settingRepo:
|
| 93 |
-
cache:
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
@@ -136,12 +157,62 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
|
| 136 |
}, nil
|
| 137 |
}
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
// SendEmail 发送邮件(使用数据库中保存的配置)
|
| 140 |
func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) error {
|
| 141 |
-
|
| 142 |
if err != nil {
|
| 143 |
return err
|
| 144 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
return s.SendEmailWithConfig(config, to, subject, body)
|
| 146 |
}
|
| 147 |
|
|
@@ -165,6 +236,42 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body
|
|
| 165 |
return smtp.SendMail(addr, auth, config.From, []string{to}, []byte(msg))
|
| 166 |
}
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
// sendMailTLS 使用TLS发送邮件
|
| 169 |
func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
| 170 |
tlsConfig := &tls.Config{
|
|
@@ -218,6 +325,30 @@ func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string,
|
|
| 218 |
return nil
|
| 219 |
}
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
// GenerateVerifyCode 生成6位数字验证码
|
| 222 |
func (s *EmailService) GenerateVerifyCode() (string, error) {
|
| 223 |
const digits = "0123456789"
|
|
@@ -386,6 +517,29 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
|
|
| 386 |
return client.Quit()
|
| 387 |
}
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters)
|
| 390 |
func (s *EmailService) GeneratePasswordResetToken() (string, error) {
|
| 391 |
bytes := make([]byte, 32)
|
|
|
|
| 1 |
package service
|
| 2 |
|
| 3 |
import (
|
| 4 |
+
"bytes"
|
| 5 |
"context"
|
| 6 |
"crypto/rand"
|
| 7 |
"crypto/subtle"
|
| 8 |
"crypto/tls"
|
| 9 |
"encoding/hex"
|
| 10 |
+
"encoding/json"
|
| 11 |
+
"errors"
|
| 12 |
"fmt"
|
| 13 |
+
"io"
|
| 14 |
"log"
|
| 15 |
"math/big"
|
| 16 |
+
"net/http"
|
| 17 |
"net/smtp"
|
| 18 |
"net/url"
|
| 19 |
"strconv"
|
| 20 |
+
"strings"
|
| 21 |
"time"
|
| 22 |
|
| 23 |
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
|
|
| 73 |
|
| 74 |
// Password reset email cooldown (prevent email bombing)
|
| 75 |
passwordResetEmailCooldown = 30 * time.Second
|
| 76 |
+
|
| 77 |
+
EmailProviderSMTP = "smtp"
|
| 78 |
+
EmailProviderResend = "resend"
|
| 79 |
+
|
| 80 |
+
defaultResendAPIBaseURL = "https://api.resend.com"
|
| 81 |
)
|
| 82 |
|
| 83 |
// SMTPConfig SMTP配置
|
|
|
|
| 91 |
UseTLS bool
|
| 92 |
}
|
| 93 |
|
| 94 |
+
type ResendConfig struct {
|
| 95 |
+
APIKey string
|
| 96 |
+
From string
|
| 97 |
+
FromName string
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
// EmailService 邮件服务
|
| 101 |
type EmailService struct {
|
| 102 |
+
settingRepo SettingRepository
|
| 103 |
+
cache EmailCache
|
| 104 |
+
httpClient *http.Client
|
| 105 |
+
resendAPIBaseURL string
|
| 106 |
}
|
| 107 |
|
| 108 |
// NewEmailService 创建邮件服务实例
|
| 109 |
func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailService {
|
| 110 |
return &EmailService{
|
| 111 |
+
settingRepo: settingRepo,
|
| 112 |
+
cache: cache,
|
| 113 |
+
httpClient: &http.Client{Timeout: 15 * time.Second},
|
| 114 |
+
resendAPIBaseURL: defaultResendAPIBaseURL,
|
| 115 |
}
|
| 116 |
}
|
| 117 |
|
|
|
|
| 157 |
}, nil
|
| 158 |
}
|
| 159 |
|
| 160 |
+
func (s *EmailService) GetResendConfig(ctx context.Context) (*ResendConfig, error) {
|
| 161 |
+
keys := []string{
|
| 162 |
+
SettingKeyResendAPIKey,
|
| 163 |
+
SettingKeyResendFrom,
|
| 164 |
+
SettingKeyResendFromName,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
| 168 |
+
if err != nil {
|
| 169 |
+
return nil, fmt.Errorf("get resend settings: %w", err)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
config := &ResendConfig{
|
| 173 |
+
APIKey: strings.TrimSpace(settings[SettingKeyResendAPIKey]),
|
| 174 |
+
From: strings.TrimSpace(settings[SettingKeyResendFrom]),
|
| 175 |
+
FromName: strings.TrimSpace(settings[SettingKeyResendFromName]),
|
| 176 |
+
}
|
| 177 |
+
if config.APIKey == "" || config.From == "" {
|
| 178 |
+
return nil, ErrEmailNotConfigured
|
| 179 |
+
}
|
| 180 |
+
return config, nil
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
func (s *EmailService) GetEmailProvider(ctx context.Context) (string, error) {
|
| 184 |
+
provider, err := s.settingRepo.GetValue(ctx, SettingKeyEmailProvider)
|
| 185 |
+
if err != nil {
|
| 186 |
+
if errors.Is(err, ErrSettingNotFound) {
|
| 187 |
+
return EmailProviderSMTP, nil
|
| 188 |
+
}
|
| 189 |
+
return "", fmt.Errorf("get email provider: %w", err)
|
| 190 |
+
}
|
| 191 |
+
provider = strings.TrimSpace(provider)
|
| 192 |
+
if provider == EmailProviderResend {
|
| 193 |
+
return EmailProviderResend, nil
|
| 194 |
+
}
|
| 195 |
+
return EmailProviderSMTP, nil
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
// SendEmail 发送邮件(使用数据库中保存的配置)
|
| 199 |
func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) error {
|
| 200 |
+
provider, err := s.GetEmailProvider(ctx)
|
| 201 |
if err != nil {
|
| 202 |
return err
|
| 203 |
}
|
| 204 |
+
if provider == EmailProviderResend {
|
| 205 |
+
config, configErr := s.GetResendConfig(ctx)
|
| 206 |
+
if configErr != nil {
|
| 207 |
+
return configErr
|
| 208 |
+
}
|
| 209 |
+
return s.SendEmailWithResendConfig(ctx, config, to, subject, body)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
config, configErr := s.GetSMTPConfig(ctx)
|
| 213 |
+
if configErr != nil {
|
| 214 |
+
return configErr
|
| 215 |
+
}
|
| 216 |
return s.SendEmailWithConfig(config, to, subject, body)
|
| 217 |
}
|
| 218 |
|
|
|
|
| 236 |
return smtp.SendMail(addr, auth, config.From, []string{to}, []byte(msg))
|
| 237 |
}
|
| 238 |
|
| 239 |
+
func (s *EmailService) SendEmailWithResendConfig(ctx context.Context, config *ResendConfig, to, subject, body string) error {
|
| 240 |
+
if config == nil || strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.From) == "" {
|
| 241 |
+
return ErrEmailNotConfigured
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
payload := map[string]any{
|
| 245 |
+
"from": formatEmailSender(config.FromName, config.From),
|
| 246 |
+
"to": []string{to},
|
| 247 |
+
"subject": subject,
|
| 248 |
+
"html": body,
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
bodyBytes, err := json.Marshal(payload)
|
| 252 |
+
if err != nil {
|
| 253 |
+
return fmt.Errorf("marshal resend payload: %w", err)
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.resendAPIBaseURL+"/emails", bytes.NewReader(bodyBytes))
|
| 257 |
+
if err != nil {
|
| 258 |
+
return fmt.Errorf("build resend request: %w", err)
|
| 259 |
+
}
|
| 260 |
+
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
| 261 |
+
req.Header.Set("Content-Type", "application/json")
|
| 262 |
+
|
| 263 |
+
resp, err := s.httpClient.Do(req)
|
| 264 |
+
if err != nil {
|
| 265 |
+
return fmt.Errorf("resend request failed: %w", err)
|
| 266 |
+
}
|
| 267 |
+
defer func() { _ = resp.Body.Close() }()
|
| 268 |
+
|
| 269 |
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
| 270 |
+
return fmt.Errorf("resend send failed: %s", readEmailAPIError(resp))
|
| 271 |
+
}
|
| 272 |
+
return nil
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
// sendMailTLS 使用TLS发送邮件
|
| 276 |
func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
| 277 |
tlsConfig := &tls.Config{
|
|
|
|
| 325 |
return nil
|
| 326 |
}
|
| 327 |
|
| 328 |
+
func formatEmailSender(name, email string) string {
|
| 329 |
+
email = strings.TrimSpace(email)
|
| 330 |
+
name = strings.TrimSpace(name)
|
| 331 |
+
if name == "" {
|
| 332 |
+
return email
|
| 333 |
+
}
|
| 334 |
+
return fmt.Sprintf("%s <%s>", name, email)
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
func readEmailAPIError(resp *http.Response) string {
|
| 338 |
+
if resp == nil {
|
| 339 |
+
return "empty response"
|
| 340 |
+
}
|
| 341 |
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
| 342 |
+
if err != nil {
|
| 343 |
+
return resp.Status
|
| 344 |
+
}
|
| 345 |
+
trimmed := strings.TrimSpace(string(body))
|
| 346 |
+
if trimmed == "" {
|
| 347 |
+
return resp.Status
|
| 348 |
+
}
|
| 349 |
+
return fmt.Sprintf("%s - %s", resp.Status, trimmed)
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
// GenerateVerifyCode 生成6位数字验证码
|
| 353 |
func (s *EmailService) GenerateVerifyCode() (string, error) {
|
| 354 |
const digits = "0123456789"
|
|
|
|
| 517 |
return client.Quit()
|
| 518 |
}
|
| 519 |
|
| 520 |
+
func (s *EmailService) TestResendConnectionWithConfig(ctx context.Context, config *ResendConfig) error {
|
| 521 |
+
if config == nil || strings.TrimSpace(config.APIKey) == "" {
|
| 522 |
+
return ErrEmailNotConfigured
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.resendAPIBaseURL+"/domains", nil)
|
| 526 |
+
if err != nil {
|
| 527 |
+
return fmt.Errorf("build resend request: %w", err)
|
| 528 |
+
}
|
| 529 |
+
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
| 530 |
+
|
| 531 |
+
resp, err := s.httpClient.Do(req)
|
| 532 |
+
if err != nil {
|
| 533 |
+
return fmt.Errorf("resend request failed: %w", err)
|
| 534 |
+
}
|
| 535 |
+
defer func() { _ = resp.Body.Close() }()
|
| 536 |
+
|
| 537 |
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
| 538 |
+
return fmt.Errorf("resend connection failed: %s", readEmailAPIError(resp))
|
| 539 |
+
}
|
| 540 |
+
return nil
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters)
|
| 544 |
func (s *EmailService) GeneratePasswordResetToken() (string, error) {
|
| 545 |
bytes := make([]byte, 32)
|
backend/internal/service/email_service_resend_test.go
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//go:build unit
|
| 2 |
+
|
| 3 |
+
package service
|
| 4 |
+
|
| 5 |
+
import (
|
| 6 |
+
"context"
|
| 7 |
+
"encoding/json"
|
| 8 |
+
"net/http"
|
| 9 |
+
"net/http/httptest"
|
| 10 |
+
"testing"
|
| 11 |
+
|
| 12 |
+
"github.com/stretchr/testify/require"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type emailServiceRepoStub struct {
|
| 16 |
+
settings map[string]string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
func (s *emailServiceRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
| 20 |
+
panic("unexpected Get call")
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s *emailServiceRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
| 24 |
+
if value, ok := s.settings[key]; ok {
|
| 25 |
+
return value, nil
|
| 26 |
+
}
|
| 27 |
+
return "", ErrSettingNotFound
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func (s *emailServiceRepoStub) Set(ctx context.Context, key, value string) error {
|
| 31 |
+
panic("unexpected Set call")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func (s *emailServiceRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
| 35 |
+
values := make(map[string]string, len(keys))
|
| 36 |
+
for _, key := range keys {
|
| 37 |
+
if value, ok := s.settings[key]; ok {
|
| 38 |
+
values[key] = value
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
return values, nil
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func (s *emailServiceRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
| 45 |
+
panic("unexpected SetMultiple call")
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func (s *emailServiceRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
| 49 |
+
panic("unexpected GetAll call")
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func (s *emailServiceRepoStub) Delete(ctx context.Context, key string) error {
|
| 53 |
+
panic("unexpected Delete call")
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestEmailService_SendEmail_UsesResendProvider(t *testing.T) {
|
| 57 |
+
type resendPayload struct {
|
| 58 |
+
From string `json:"from"`
|
| 59 |
+
To []string `json:"to"`
|
| 60 |
+
Subject string `json:"subject"`
|
| 61 |
+
HTML string `json:"html"`
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
var (
|
| 65 |
+
gotMethod string
|
| 66 |
+
gotPath string
|
| 67 |
+
gotAuth string
|
| 68 |
+
gotPayload resendPayload
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 72 |
+
gotMethod = r.Method
|
| 73 |
+
gotPath = r.URL.Path
|
| 74 |
+
gotAuth = r.Header.Get("Authorization")
|
| 75 |
+
|
| 76 |
+
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotPayload))
|
| 77 |
+
w.WriteHeader(http.StatusAccepted)
|
| 78 |
+
}))
|
| 79 |
+
defer server.Close()
|
| 80 |
+
|
| 81 |
+
repo := &emailServiceRepoStub{
|
| 82 |
+
settings: map[string]string{
|
| 83 |
+
SettingKeyEmailProvider: EmailProviderResend,
|
| 84 |
+
SettingKeyResendAPIKey: "re_test_key",
|
| 85 |
+
SettingKeyResendFrom: "noreply@example.com",
|
| 86 |
+
SettingKeyResendFromName: "XAPI",
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
svc := NewEmailService(repo, nil)
|
| 91 |
+
svc.httpClient = server.Client()
|
| 92 |
+
svc.resendAPIBaseURL = server.URL
|
| 93 |
+
|
| 94 |
+
err := svc.SendEmail(context.Background(), "user@example.com", "Hello", "<p>World</p>")
|
| 95 |
+
require.NoError(t, err)
|
| 96 |
+
require.Equal(t, http.MethodPost, gotMethod)
|
| 97 |
+
require.Equal(t, "/emails", gotPath)
|
| 98 |
+
require.Equal(t, "Bearer re_test_key", gotAuth)
|
| 99 |
+
require.Equal(t, "XAPI <noreply@example.com>", gotPayload.From)
|
| 100 |
+
require.Equal(t, []string{"user@example.com"}, gotPayload.To)
|
| 101 |
+
require.Equal(t, "Hello", gotPayload.Subject)
|
| 102 |
+
require.Equal(t, "<p>World</p>", gotPayload.HTML)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
func TestEmailService_TestResendConnectionWithConfig(t *testing.T) {
|
| 106 |
+
var (
|
| 107 |
+
gotMethod string
|
| 108 |
+
gotPath string
|
| 109 |
+
gotAuth string
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 113 |
+
gotMethod = r.Method
|
| 114 |
+
gotPath = r.URL.Path
|
| 115 |
+
gotAuth = r.Header.Get("Authorization")
|
| 116 |
+
w.WriteHeader(http.StatusOK)
|
| 117 |
+
}))
|
| 118 |
+
defer server.Close()
|
| 119 |
+
|
| 120 |
+
svc := NewEmailService(&emailServiceRepoStub{}, nil)
|
| 121 |
+
svc.httpClient = server.Client()
|
| 122 |
+
svc.resendAPIBaseURL = server.URL
|
| 123 |
+
|
| 124 |
+
err := svc.TestResendConnectionWithConfig(context.Background(), &ResendConfig{
|
| 125 |
+
APIKey: "re_test_key",
|
| 126 |
+
})
|
| 127 |
+
require.NoError(t, err)
|
| 128 |
+
require.Equal(t, http.MethodGet, gotMethod)
|
| 129 |
+
require.Equal(t, "/domains", gotPath)
|
| 130 |
+
require.Equal(t, "Bearer re_test_key", gotAuth)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
func TestEmailService_SendEmailWithResendConfig_ReportsAPIError(t *testing.T) {
|
| 134 |
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 135 |
+
w.WriteHeader(http.StatusBadRequest)
|
| 136 |
+
_, _ = w.Write([]byte(`{"message":"invalid from address"}`))
|
| 137 |
+
}))
|
| 138 |
+
defer server.Close()
|
| 139 |
+
|
| 140 |
+
svc := NewEmailService(&emailServiceRepoStub{}, nil)
|
| 141 |
+
svc.httpClient = server.Client()
|
| 142 |
+
svc.resendAPIBaseURL = server.URL
|
| 143 |
+
|
| 144 |
+
err := svc.SendEmailWithResendConfig(context.Background(), &ResendConfig{
|
| 145 |
+
APIKey: "re_test_key",
|
| 146 |
+
From: "noreply@example.com",
|
| 147 |
+
FromName: "XAPI",
|
| 148 |
+
}, "user@example.com", "Hello", "<p>World</p>")
|
| 149 |
+
|
| 150 |
+
require.Error(t, err)
|
| 151 |
+
require.ErrorContains(t, err, "400 Bad Request")
|
| 152 |
+
require.ErrorContains(t, err, "invalid from address")
|
| 153 |
+
}
|
backend/internal/service/setting_service.go
CHANGED
|
@@ -398,6 +398,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|
| 398 |
normalizedWhitelist = []string{}
|
| 399 |
}
|
| 400 |
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
updates := make(map[string]string)
|
| 403 |
|
|
@@ -420,11 +423,17 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|
| 420 |
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
| 421 |
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
| 422 |
if settings.SMTPPassword != "" {
|
| 423 |
-
|
| 424 |
}
|
| 425 |
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
| 426 |
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
| 427 |
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
|
| 429 |
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
| 430 |
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
|
@@ -745,6 +754,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|
| 745 |
SettingKeyDefaultSubscriptions: "[]",
|
| 746 |
SettingKeySMTPPort: "587",
|
| 747 |
SettingKeySMTPUseTLS: "false",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
// Model fallback defaults
|
| 749 |
SettingKeyEnableModelFallback: "false",
|
| 750 |
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
|
@@ -790,6 +803,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|
| 790 |
SMTPFromName: settings[SettingKeySMTPFromName],
|
| 791 |
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
| 792 |
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
| 794 |
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
| 795 |
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
|
@@ -831,7 +848,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|
| 831 |
|
| 832 |
// 敏感信息直接返回,方便测试连接时使用
|
| 833 |
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
|
|
|
| 834 |
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
|
|
|
|
|
|
|
|
|
| 835 |
|
| 836 |
// LinuxDo Connect 设置:
|
| 837 |
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
|
|
|
| 398 |
normalizedWhitelist = []string{}
|
| 399 |
}
|
| 400 |
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
| 401 |
+
if settings.EmailProvider == "" {
|
| 402 |
+
settings.EmailProvider = EmailProviderSMTP
|
| 403 |
+
}
|
| 404 |
|
| 405 |
updates := make(map[string]string)
|
| 406 |
|
|
|
|
| 423 |
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
| 424 |
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
| 425 |
if settings.SMTPPassword != "" {
|
| 426 |
+
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
| 427 |
}
|
| 428 |
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
| 429 |
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
| 430 |
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
| 431 |
+
updates[SettingKeyEmailProvider] = settings.EmailProvider
|
| 432 |
+
if settings.ResendAPIKey != "" {
|
| 433 |
+
updates[SettingKeyResendAPIKey] = settings.ResendAPIKey
|
| 434 |
+
}
|
| 435 |
+
updates[SettingKeyResendFrom] = settings.ResendFrom
|
| 436 |
+
updates[SettingKeyResendFromName] = settings.ResendFromName
|
| 437 |
|
| 438 |
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
| 439 |
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
|
|
|
| 754 |
SettingKeyDefaultSubscriptions: "[]",
|
| 755 |
SettingKeySMTPPort: "587",
|
| 756 |
SettingKeySMTPUseTLS: "false",
|
| 757 |
+
SettingKeyEmailProvider: EmailProviderSMTP,
|
| 758 |
+
SettingKeyResendAPIKey: "",
|
| 759 |
+
SettingKeyResendFrom: "",
|
| 760 |
+
SettingKeyResendFromName: "",
|
| 761 |
// Model fallback defaults
|
| 762 |
SettingKeyEnableModelFallback: "false",
|
| 763 |
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
|
|
|
| 803 |
SMTPFromName: settings[SettingKeySMTPFromName],
|
| 804 |
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
| 805 |
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
| 806 |
+
EmailProvider: s.getStringOrDefault(settings, SettingKeyEmailProvider, EmailProviderSMTP),
|
| 807 |
+
ResendFrom: settings[SettingKeyResendFrom],
|
| 808 |
+
ResendFromName: settings[SettingKeyResendFromName],
|
| 809 |
+
ResendAPIKeyConfigured: settings[SettingKeyResendAPIKey] != "",
|
| 810 |
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
| 811 |
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
| 812 |
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
|
|
|
| 848 |
|
| 849 |
// 敏感信息直接返回,方便测试连接时使用
|
| 850 |
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
| 851 |
+
result.ResendAPIKey = settings[SettingKeyResendAPIKey]
|
| 852 |
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
| 853 |
+
if result.EmailProvider != EmailProviderSMTP && result.EmailProvider != EmailProviderResend {
|
| 854 |
+
result.EmailProvider = EmailProviderSMTP
|
| 855 |
+
}
|
| 856 |
|
| 857 |
// LinuxDo Connect 设置:
|
| 858 |
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
backend/internal/service/setting_service_update_test.go
CHANGED
|
@@ -194,6 +194,41 @@ func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(
|
|
| 194 |
require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
|
| 195 |
}
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
|
| 198 |
got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`)
|
| 199 |
require.Equal(t, []DefaultSubscriptionSetting{
|
|
|
|
| 194 |
require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
|
| 195 |
}
|
| 196 |
|
| 197 |
+
func TestSettingService_UpdateSettings_PersistsResendSettings(t *testing.T) {
|
| 198 |
+
repo := &settingUpdateRepoStub{}
|
| 199 |
+
svc := NewSettingService(repo, &config.Config{})
|
| 200 |
+
|
| 201 |
+
err := svc.UpdateSettings(context.Background(), &SystemSettings{
|
| 202 |
+
EmailProvider: EmailProviderResend,
|
| 203 |
+
ResendAPIKey: "re_test_key",
|
| 204 |
+
ResendFrom: "noreply@example.com",
|
| 205 |
+
ResendFromName: "XAPI",
|
| 206 |
+
})
|
| 207 |
+
|
| 208 |
+
require.NoError(t, err)
|
| 209 |
+
require.Equal(t, EmailProviderResend, repo.updates[SettingKeyEmailProvider])
|
| 210 |
+
require.Equal(t, "re_test_key", repo.updates[SettingKeyResendAPIKey])
|
| 211 |
+
require.Equal(t, "noreply@example.com", repo.updates[SettingKeyResendFrom])
|
| 212 |
+
require.Equal(t, "XAPI", repo.updates[SettingKeyResendFromName])
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
func TestSettingService_ParseSettings_ExposesResendState(t *testing.T) {
|
| 216 |
+
svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{})
|
| 217 |
+
|
| 218 |
+
settings := svc.parseSettings(map[string]string{
|
| 219 |
+
SettingKeyEmailProvider: EmailProviderResend,
|
| 220 |
+
SettingKeyResendAPIKey: "re_test_key",
|
| 221 |
+
SettingKeyResendFrom: "noreply@example.com",
|
| 222 |
+
SettingKeyResendFromName: "XAPI",
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
require.Equal(t, EmailProviderResend, settings.EmailProvider)
|
| 226 |
+
require.True(t, settings.ResendAPIKeyConfigured)
|
| 227 |
+
require.Equal(t, "re_test_key", settings.ResendAPIKey)
|
| 228 |
+
require.Equal(t, "noreply@example.com", settings.ResendFrom)
|
| 229 |
+
require.Equal(t, "XAPI", settings.ResendFromName)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
|
| 233 |
got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`)
|
| 234 |
require.Equal(t, []DefaultSubscriptionSetting{
|
backend/internal/service/settings_view.go
CHANGED
|
@@ -18,6 +18,11 @@ type SystemSettings struct {
|
|
| 18 |
SMTPFrom string
|
| 19 |
SMTPFromName string
|
| 20 |
SMTPUseTLS bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
TurnstileEnabled bool
|
| 23 |
TurnstileSiteKey string
|
|
|
|
| 18 |
SMTPFrom string
|
| 19 |
SMTPFromName string
|
| 20 |
SMTPUseTLS bool
|
| 21 |
+
EmailProvider string
|
| 22 |
+
ResendAPIKey string
|
| 23 |
+
ResendAPIKeyConfigured bool
|
| 24 |
+
ResendFrom string
|
| 25 |
+
ResendFromName string
|
| 26 |
|
| 27 |
TurnstileEnabled bool
|
| 28 |
TurnstileSiteKey string
|
frontend/src/api/admin/settings.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface SystemSettings {
|
|
| 51 |
smtp_from_email: string
|
| 52 |
smtp_from_name: string
|
| 53 |
smtp_use_tls: boolean
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
// Cloudflare Turnstile settings
|
| 55 |
turnstile_enabled: boolean
|
| 56 |
turnstile_site_key: string
|
|
@@ -119,6 +123,10 @@ export interface UpdateSettingsRequest {
|
|
| 119 |
smtp_from_email?: string
|
| 120 |
smtp_from_name?: string
|
| 121 |
smtp_use_tls?: boolean
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
turnstile_enabled?: boolean
|
| 123 |
turnstile_site_key?: string
|
| 124 |
turnstile_secret_key?: string
|
|
@@ -172,6 +180,10 @@ export interface TestSmtpRequest {
|
|
| 172 |
smtp_use_tls: boolean
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
/**
|
| 176 |
* Test SMTP connection with provided config
|
| 177 |
* @param config - SMTP configuration to test
|
|
@@ -182,6 +194,11 @@ export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ mes
|
|
| 182 |
return data
|
| 183 |
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
/**
|
| 186 |
* Send test email request
|
| 187 |
*/
|
|
@@ -196,6 +213,13 @@ export interface SendTestEmailRequest {
|
|
| 196 |
smtp_use_tls: boolean
|
| 197 |
}
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
/**
|
| 200 |
* Send test email with provided SMTP config
|
| 201 |
* @param request - Email address and SMTP config
|
|
@@ -209,6 +233,16 @@ export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ me
|
|
| 209 |
return data
|
| 210 |
}
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
/**
|
| 213 |
* Admin API Key status response
|
| 214 |
*/
|
|
@@ -524,7 +558,9 @@ export const settingsAPI = {
|
|
| 524 |
getSettings,
|
| 525 |
updateSettings,
|
| 526 |
testSmtpConnection,
|
|
|
|
| 527 |
sendTestEmail,
|
|
|
|
| 528 |
getAdminApiKey,
|
| 529 |
regenerateAdminApiKey,
|
| 530 |
deleteAdminApiKey,
|
|
|
|
| 51 |
smtp_from_email: string
|
| 52 |
smtp_from_name: string
|
| 53 |
smtp_use_tls: boolean
|
| 54 |
+
email_provider: 'smtp' | 'resend' | string
|
| 55 |
+
resend_api_key_configured: boolean
|
| 56 |
+
resend_from_email: string
|
| 57 |
+
resend_from_name: string
|
| 58 |
// Cloudflare Turnstile settings
|
| 59 |
turnstile_enabled: boolean
|
| 60 |
turnstile_site_key: string
|
|
|
|
| 123 |
smtp_from_email?: string
|
| 124 |
smtp_from_name?: string
|
| 125 |
smtp_use_tls?: boolean
|
| 126 |
+
email_provider?: 'smtp' | 'resend' | string
|
| 127 |
+
resend_api_key?: string
|
| 128 |
+
resend_from_email?: string
|
| 129 |
+
resend_from_name?: string
|
| 130 |
turnstile_enabled?: boolean
|
| 131 |
turnstile_site_key?: string
|
| 132 |
turnstile_secret_key?: string
|
|
|
|
| 180 |
smtp_use_tls: boolean
|
| 181 |
}
|
| 182 |
|
| 183 |
+
export interface TestResendRequest {
|
| 184 |
+
resend_api_key: string
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
/**
|
| 188 |
* Test SMTP connection with provided config
|
| 189 |
* @param config - SMTP configuration to test
|
|
|
|
| 194 |
return data
|
| 195 |
}
|
| 196 |
|
| 197 |
+
export async function testResendConnection(config: TestResendRequest): Promise<{ message: string }> {
|
| 198 |
+
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-resend', config)
|
| 199 |
+
return data
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
/**
|
| 203 |
* Send test email request
|
| 204 |
*/
|
|
|
|
| 213 |
smtp_use_tls: boolean
|
| 214 |
}
|
| 215 |
|
| 216 |
+
export interface SendResendTestEmailRequest {
|
| 217 |
+
email: string
|
| 218 |
+
resend_api_key: string
|
| 219 |
+
resend_from_email: string
|
| 220 |
+
resend_from_name: string
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
/**
|
| 224 |
* Send test email with provided SMTP config
|
| 225 |
* @param request - Email address and SMTP config
|
|
|
|
| 233 |
return data
|
| 234 |
}
|
| 235 |
|
| 236 |
+
export async function sendResendTestEmail(
|
| 237 |
+
request: SendResendTestEmailRequest
|
| 238 |
+
): Promise<{ message: string }> {
|
| 239 |
+
const { data } = await apiClient.post<{ message: string }>(
|
| 240 |
+
'/admin/settings/send-test-email-resend',
|
| 241 |
+
request
|
| 242 |
+
)
|
| 243 |
+
return data
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
/**
|
| 247 |
* Admin API Key status response
|
| 248 |
*/
|
|
|
|
| 558 |
getSettings,
|
| 559 |
updateSettings,
|
| 560 |
testSmtpConnection,
|
| 561 |
+
testResendConnection,
|
| 562 |
sendTestEmail,
|
| 563 |
+
sendResendTestEmail,
|
| 564 |
getAdminApiKey,
|
| 565 |
regenerateAdminApiKey,
|
| 566 |
deleteAdminApiKey,
|
frontend/src/i18n/locales/en.ts
CHANGED
|
@@ -4245,9 +4245,33 @@ export default {
|
|
| 4245 |
useTls: 'Use TLS',
|
| 4246 |
useTlsHint: 'Enable TLS encryption for SMTP connection'
|
| 4247 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4248 |
testEmail: {
|
| 4249 |
title: 'Send Test Email',
|
| 4250 |
description: 'Send a test email to verify your SMTP configuration',
|
|
|
|
| 4251 |
recipientEmail: 'Recipient Email',
|
| 4252 |
recipientEmailPlaceholder: "test{'@'}example.com",
|
| 4253 |
sendTestEmail: 'Send Test Email',
|
|
|
|
| 4245 |
useTls: 'Use TLS',
|
| 4246 |
useTlsHint: 'Enable TLS encryption for SMTP connection'
|
| 4247 |
},
|
| 4248 |
+
resend: {
|
| 4249 |
+
title: 'Resend API Settings',
|
| 4250 |
+
description: 'Configure Resend API delivery for environments where SMTP ports are blocked',
|
| 4251 |
+
testConnection: 'Test API Connection',
|
| 4252 |
+
testing: 'Testing...',
|
| 4253 |
+
provider: 'Active Email Provider',
|
| 4254 |
+
providerHint: 'System emails such as verification codes and password reset links use the selected provider',
|
| 4255 |
+
providerSmtp: 'SMTP',
|
| 4256 |
+
providerResend: 'Resend API',
|
| 4257 |
+
apiKey: 'Resend API Key',
|
| 4258 |
+
apiKeyPlaceholder: 're_...',
|
| 4259 |
+
apiKeyHint: 'Leave empty to keep the existing API key',
|
| 4260 |
+
apiKeyConfiguredPlaceholder: 're_********************************',
|
| 4261 |
+
apiKeyConfiguredHint: 'API key configured. Leave empty to keep the current value.',
|
| 4262 |
+
fromEmail: 'Resend From Email',
|
| 4263 |
+
fromEmailPlaceholder: "noreply{'@'}your-domain.com",
|
| 4264 |
+
fromName: 'Resend From Name',
|
| 4265 |
+
fromNamePlaceholder: 'Sub2API',
|
| 4266 |
+
connectionSuccess: 'Resend API connection successful',
|
| 4267 |
+
connectionFailed: 'Resend API connection test failed',
|
| 4268 |
+
testEmailSent: 'Resend test email sent successfully',
|
| 4269 |
+
testEmailFailed: 'Failed to send Resend test email'
|
| 4270 |
+
},
|
| 4271 |
testEmail: {
|
| 4272 |
title: 'Send Test Email',
|
| 4273 |
description: 'Send a test email to verify your SMTP configuration',
|
| 4274 |
+
descriptionResend: 'Send a test email using the currently selected Resend API configuration',
|
| 4275 |
recipientEmail: 'Recipient Email',
|
| 4276 |
recipientEmailPlaceholder: "test{'@'}example.com",
|
| 4277 |
sendTestEmail: 'Send Test Email',
|
frontend/src/i18n/locales/zh.ts
CHANGED
|
@@ -4410,9 +4410,33 @@ export default {
|
|
| 4410 |
useTls: '使用 TLS',
|
| 4411 |
useTlsHint: '为 SMTP 连接启用 TLS 加密'
|
| 4412 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4413 |
testEmail: {
|
| 4414 |
title: '发送测试邮件',
|
| 4415 |
description: '发送测试邮件以验证 SMTP 配置',
|
|
|
|
| 4416 |
recipientEmail: '收件人邮箱',
|
| 4417 |
recipientEmailPlaceholder: "test{'@'}example.com",
|
| 4418 |
sendTestEmail: '发送测试邮件',
|
|
|
|
| 4410 |
useTls: '使用 TLS',
|
| 4411 |
useTlsHint: '为 SMTP 连接启用 TLS 加密'
|
| 4412 |
},
|
| 4413 |
+
resend: {
|
| 4414 |
+
title: 'Resend API 设置',
|
| 4415 |
+
description: '为 SMTP 端口受限的环境配置 Resend API 发信',
|
| 4416 |
+
testConnection: '测试 API 连接',
|
| 4417 |
+
testing: '测试中...',
|
| 4418 |
+
provider: '当前邮件提供商',
|
| 4419 |
+
providerHint: '验证码、重置密码等系统邮件会使用这里选择的提供商发送',
|
| 4420 |
+
providerSmtp: 'SMTP',
|
| 4421 |
+
providerResend: 'Resend API',
|
| 4422 |
+
apiKey: 'Resend API Key',
|
| 4423 |
+
apiKeyPlaceholder: 're_...',
|
| 4424 |
+
apiKeyHint: '留空则保留当前 API Key',
|
| 4425 |
+
apiKeyConfiguredPlaceholder: 're_********************************',
|
| 4426 |
+
apiKeyConfiguredHint: 'API Key 已配置,留空则保留当前值。',
|
| 4427 |
+
fromEmail: 'Resend 发件邮箱',
|
| 4428 |
+
fromEmailPlaceholder: "noreply{'@'}your-domain.com",
|
| 4429 |
+
fromName: 'Resend 发件人名称',
|
| 4430 |
+
fromNamePlaceholder: 'Sub2API',
|
| 4431 |
+
connectionSuccess: 'Resend API 连接成功',
|
| 4432 |
+
connectionFailed: 'Resend API 连接测试失败',
|
| 4433 |
+
testEmailSent: 'Resend 测试邮件发送成功',
|
| 4434 |
+
testEmailFailed: 'Resend 测试邮件发送失败'
|
| 4435 |
+
},
|
| 4436 |
testEmail: {
|
| 4437 |
title: '发送测试邮件',
|
| 4438 |
description: '发送测试邮件以验证 SMTP 配置',
|
| 4439 |
+
descriptionResend: '使用当前选择的 Resend API 配置发送测试邮件',
|
| 4440 |
recipientEmail: '收件人邮箱',
|
| 4441 |
recipientEmailPlaceholder: "test{'@'}example.com",
|
| 4442 |
sendTestEmail: '发送测试邮件',
|
frontend/src/router/__tests__/routes.spec.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 2 |
+
import { createPinia, setActivePinia } from 'pinia'
|
| 3 |
+
|
| 4 |
+
const mockAuthStore = {
|
| 5 |
+
isAuthenticated: false,
|
| 6 |
+
isAdmin: false,
|
| 7 |
+
isSimpleMode: false,
|
| 8 |
+
checkAuth: vi.fn()
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const mockAppStore = {
|
| 12 |
+
siteName: 'Test Site',
|
| 13 |
+
backendModeEnabled: false,
|
| 14 |
+
cachedPublicSettings: null
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const mockAdminSettingsStore = {
|
| 18 |
+
customMenuItems: []
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
vi.mock('@/composables/useNavigationLoading', () => ({
|
| 22 |
+
useNavigationLoadingState: () => ({
|
| 23 |
+
startNavigation: vi.fn(),
|
| 24 |
+
endNavigation: vi.fn(),
|
| 25 |
+
isLoading: { value: false }
|
| 26 |
+
})
|
| 27 |
+
}))
|
| 28 |
+
|
| 29 |
+
vi.mock('@/composables/useRoutePrefetch', () => ({
|
| 30 |
+
useRoutePrefetch: () => ({
|
| 31 |
+
triggerPrefetch: vi.fn(),
|
| 32 |
+
cancelPendingPrefetch: vi.fn(),
|
| 33 |
+
resetPrefetchState: vi.fn()
|
| 34 |
+
})
|
| 35 |
+
}))
|
| 36 |
+
|
| 37 |
+
vi.mock('@/stores/auth', () => ({
|
| 38 |
+
useAuthStore: () => mockAuthStore
|
| 39 |
+
}))
|
| 40 |
+
|
| 41 |
+
vi.mock('@/stores/app', () => ({
|
| 42 |
+
useAppStore: () => mockAppStore
|
| 43 |
+
}))
|
| 44 |
+
|
| 45 |
+
vi.mock('@/stores/adminSettings', () => ({
|
| 46 |
+
useAdminSettingsStore: () => mockAdminSettingsStore
|
| 47 |
+
}))
|
| 48 |
+
|
| 49 |
+
describe('router routes', () => {
|
| 50 |
+
beforeEach(() => {
|
| 51 |
+
setActivePinia(createPinia())
|
| 52 |
+
mockAuthStore.isAuthenticated = false
|
| 53 |
+
mockAuthStore.isAdmin = false
|
| 54 |
+
mockAuthStore.isSimpleMode = false
|
| 55 |
+
mockAuthStore.checkAuth.mockClear()
|
| 56 |
+
mockAppStore.siteName = 'Test Site'
|
| 57 |
+
mockAppStore.backendModeEnabled = false
|
| 58 |
+
mockAppStore.cachedPublicSettings = null
|
| 59 |
+
mockAdminSettingsStore.customMenuItems = []
|
| 60 |
+
vi.resetModules()
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
it('redirects / to /login', async () => {
|
| 64 |
+
const { default: router } = await import('../index')
|
| 65 |
+
const rootRoute = router.options.routes.find((route) => route.path === '/')
|
| 66 |
+
|
| 67 |
+
expect(rootRoute?.redirect).toBe('/login')
|
| 68 |
+
})
|
| 69 |
+
})
|
frontend/src/router/index.ts
CHANGED
|
@@ -115,7 +115,7 @@ const routes: RouteRecordRaw[] = [
|
|
| 115 |
// ==================== User Routes ====================
|
| 116 |
{
|
| 117 |
path: '/',
|
| 118 |
-
redirect: '/
|
| 119 |
},
|
| 120 |
{
|
| 121 |
path: '/dashboard',
|
|
|
|
| 115 |
// ==================== User Routes ====================
|
| 116 |
{
|
| 117 |
path: '/',
|
| 118 |
+
redirect: '/login'
|
| 119 |
},
|
| 120 |
{
|
| 121 |
path: '/dashboard',
|
frontend/src/views/admin/SettingsView.vue
CHANGED
|
@@ -1706,6 +1706,109 @@
|
|
| 1706 |
</div>
|
| 1707 |
</div>
|
| 1708 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1709 |
<!-- Send Test Email - Only show when email verification is enabled -->
|
| 1710 |
<div v-if="form.email_verify_enabled" class="card">
|
| 1711 |
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
|
@@ -1713,7 +1816,11 @@
|
|
| 1713 |
{{ t('admin.settings.testEmail.title') }}
|
| 1714 |
</h2>
|
| 1715 |
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
| 1716 |
-
{{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1717 |
</p>
|
| 1718 |
</div>
|
| 1719 |
<div class="p-6">
|
|
@@ -1851,6 +1958,7 @@ const { copyToClipboard } = useClipboard()
|
|
| 1851 |
const loading = ref(true)
|
| 1852 |
const saving = ref(false)
|
| 1853 |
const testingSmtp = ref(false)
|
|
|
|
| 1854 |
const sendingTestEmail = ref(false)
|
| 1855 |
const testEmailAddress = ref('')
|
| 1856 |
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
|
@@ -1916,6 +2024,7 @@ interface DefaultSubscriptionGroupOption {
|
|
| 1916 |
|
| 1917 |
type SettingsForm = SystemSettings & {
|
| 1918 |
smtp_password: string
|
|
|
|
| 1919 |
turnstile_secret_key: string
|
| 1920 |
linuxdo_connect_client_secret: string
|
| 1921 |
}
|
|
@@ -1954,6 +2063,11 @@ const form = reactive<SettingsForm>({
|
|
| 1954 |
smtp_from_email: '',
|
| 1955 |
smtp_from_name: '',
|
| 1956 |
smtp_use_tls: true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1957 |
// Cloudflare Turnstile
|
| 1958 |
turnstile_enabled: false,
|
| 1959 |
turnstile_site_key: '',
|
|
@@ -2133,6 +2247,7 @@ async function loadSettings() {
|
|
| 2133 |
)
|
| 2134 |
registrationEmailSuffixWhitelistDraft.value = ''
|
| 2135 |
form.smtp_password = ''
|
|
|
|
| 2136 |
form.turnstile_secret_key = ''
|
| 2137 |
form.linuxdo_connect_client_secret = ''
|
| 2138 |
} catch (error: any) {
|
|
@@ -2232,6 +2347,10 @@ async function saveSettings() {
|
|
| 2232 |
smtp_from_email: form.smtp_from_email,
|
| 2233 |
smtp_from_name: form.smtp_from_name,
|
| 2234 |
smtp_use_tls: form.smtp_use_tls,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2235 |
turnstile_enabled: form.turnstile_enabled,
|
| 2236 |
turnstile_site_key: form.turnstile_site_key,
|
| 2237 |
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
|
@@ -2257,6 +2376,7 @@ async function saveSettings() {
|
|
| 2257 |
)
|
| 2258 |
registrationEmailSuffixWhitelistDraft.value = ''
|
| 2259 |
form.smtp_password = ''
|
|
|
|
| 2260 |
form.turnstile_secret_key = ''
|
| 2261 |
form.linuxdo_connect_client_secret = ''
|
| 2262 |
// Refresh cached settings so sidebar/header update immediately
|
|
@@ -2293,6 +2413,22 @@ async function testSmtpConnection() {
|
|
| 2293 |
}
|
| 2294 |
}
|
| 2295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2296 |
async function sendTestEmail() {
|
| 2297 |
if (!testEmailAddress.value) {
|
| 2298 |
appStore.showError(t('admin.settings.testEmail.enterRecipientHint'))
|
|
@@ -2301,21 +2437,37 @@ async function sendTestEmail() {
|
|
| 2301 |
|
| 2302 |
sendingTestEmail.value = true
|
| 2303 |
try {
|
| 2304 |
-
const result =
|
| 2305 |
-
|
| 2306 |
-
|
| 2307 |
-
|
| 2308 |
-
|
| 2309 |
-
|
| 2310 |
-
|
| 2311 |
-
|
| 2312 |
-
|
| 2313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2314 |
// API returns { message: "..." } on success, errors are thrown as exceptions
|
| 2315 |
-
appStore.showSuccess(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2316 |
} catch (error: any) {
|
| 2317 |
appStore.showError(
|
| 2318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2319 |
)
|
| 2320 |
} finally {
|
| 2321 |
sendingTestEmail.value = false
|
|
|
|
| 1706 |
</div>
|
| 1707 |
</div>
|
| 1708 |
|
| 1709 |
+
<div v-if="form.email_verify_enabled" class="card">
|
| 1710 |
+
<div
|
| 1711 |
+
class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
| 1712 |
+
>
|
| 1713 |
+
<div>
|
| 1714 |
+
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
| 1715 |
+
{{ t('admin.settings.resend.title') }}
|
| 1716 |
+
</h2>
|
| 1717 |
+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
| 1718 |
+
{{ t('admin.settings.resend.description') }}
|
| 1719 |
+
</p>
|
| 1720 |
+
</div>
|
| 1721 |
+
<button
|
| 1722 |
+
type="button"
|
| 1723 |
+
@click="testResendConnection"
|
| 1724 |
+
:disabled="testingResend"
|
| 1725 |
+
class="btn btn-secondary btn-sm"
|
| 1726 |
+
>
|
| 1727 |
+
<svg v-if="testingResend" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 1728 |
+
<circle
|
| 1729 |
+
class="opacity-25"
|
| 1730 |
+
cx="12"
|
| 1731 |
+
cy="12"
|
| 1732 |
+
r="10"
|
| 1733 |
+
stroke="currentColor"
|
| 1734 |
+
stroke-width="4"
|
| 1735 |
+
></circle>
|
| 1736 |
+
<path
|
| 1737 |
+
class="opacity-75"
|
| 1738 |
+
fill="currentColor"
|
| 1739 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 1740 |
+
></path>
|
| 1741 |
+
</svg>
|
| 1742 |
+
{{
|
| 1743 |
+
testingResend
|
| 1744 |
+
? t('admin.settings.resend.testing')
|
| 1745 |
+
: t('admin.settings.resend.testConnection')
|
| 1746 |
+
}}
|
| 1747 |
+
</button>
|
| 1748 |
+
</div>
|
| 1749 |
+
<div class="space-y-6 p-6">
|
| 1750 |
+
<div>
|
| 1751 |
+
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 1752 |
+
{{ t('admin.settings.resend.provider') }}
|
| 1753 |
+
</label>
|
| 1754 |
+
<select v-model="form.email_provider" class="input">
|
| 1755 |
+
<option value="smtp">{{ t('admin.settings.resend.providerSmtp') }}</option>
|
| 1756 |
+
<option value="resend">{{ t('admin.settings.resend.providerResend') }}</option>
|
| 1757 |
+
</select>
|
| 1758 |
+
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
| 1759 |
+
{{ t('admin.settings.resend.providerHint') }}
|
| 1760 |
+
</p>
|
| 1761 |
+
</div>
|
| 1762 |
+
|
| 1763 |
+
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
| 1764 |
+
<div class="md:col-span-2">
|
| 1765 |
+
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 1766 |
+
{{ t('admin.settings.resend.apiKey') }}
|
| 1767 |
+
</label>
|
| 1768 |
+
<input
|
| 1769 |
+
v-model="form.resend_api_key"
|
| 1770 |
+
type="password"
|
| 1771 |
+
class="input"
|
| 1772 |
+
:placeholder="
|
| 1773 |
+
form.resend_api_key_configured
|
| 1774 |
+
? t('admin.settings.resend.apiKeyConfiguredPlaceholder')
|
| 1775 |
+
: t('admin.settings.resend.apiKeyPlaceholder')
|
| 1776 |
+
"
|
| 1777 |
+
/>
|
| 1778 |
+
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
| 1779 |
+
{{
|
| 1780 |
+
form.resend_api_key_configured
|
| 1781 |
+
? t('admin.settings.resend.apiKeyConfiguredHint')
|
| 1782 |
+
: t('admin.settings.resend.apiKeyHint')
|
| 1783 |
+
}}
|
| 1784 |
+
</p>
|
| 1785 |
+
</div>
|
| 1786 |
+
<div>
|
| 1787 |
+
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 1788 |
+
{{ t('admin.settings.resend.fromEmail') }}
|
| 1789 |
+
</label>
|
| 1790 |
+
<input
|
| 1791 |
+
v-model="form.resend_from_email"
|
| 1792 |
+
type="email"
|
| 1793 |
+
class="input"
|
| 1794 |
+
:placeholder="t('admin.settings.resend.fromEmailPlaceholder')"
|
| 1795 |
+
/>
|
| 1796 |
+
</div>
|
| 1797 |
+
<div>
|
| 1798 |
+
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 1799 |
+
{{ t('admin.settings.resend.fromName') }}
|
| 1800 |
+
</label>
|
| 1801 |
+
<input
|
| 1802 |
+
v-model="form.resend_from_name"
|
| 1803 |
+
type="text"
|
| 1804 |
+
class="input"
|
| 1805 |
+
:placeholder="t('admin.settings.resend.fromNamePlaceholder')"
|
| 1806 |
+
/>
|
| 1807 |
+
</div>
|
| 1808 |
+
</div>
|
| 1809 |
+
</div>
|
| 1810 |
+
</div>
|
| 1811 |
+
|
| 1812 |
<!-- Send Test Email - Only show when email verification is enabled -->
|
| 1813 |
<div v-if="form.email_verify_enabled" class="card">
|
| 1814 |
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
|
|
|
| 1816 |
{{ t('admin.settings.testEmail.title') }}
|
| 1817 |
</h2>
|
| 1818 |
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
| 1819 |
+
{{
|
| 1820 |
+
form.email_provider === 'resend'
|
| 1821 |
+
? t('admin.settings.testEmail.descriptionResend')
|
| 1822 |
+
: t('admin.settings.testEmail.description')
|
| 1823 |
+
}}
|
| 1824 |
</p>
|
| 1825 |
</div>
|
| 1826 |
<div class="p-6">
|
|
|
|
| 1958 |
const loading = ref(true)
|
| 1959 |
const saving = ref(false)
|
| 1960 |
const testingSmtp = ref(false)
|
| 1961 |
+
const testingResend = ref(false)
|
| 1962 |
const sendingTestEmail = ref(false)
|
| 1963 |
const testEmailAddress = ref('')
|
| 1964 |
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
|
|
|
| 2024 |
|
| 2025 |
type SettingsForm = SystemSettings & {
|
| 2026 |
smtp_password: string
|
| 2027 |
+
resend_api_key: string
|
| 2028 |
turnstile_secret_key: string
|
| 2029 |
linuxdo_connect_client_secret: string
|
| 2030 |
}
|
|
|
|
| 2063 |
smtp_from_email: '',
|
| 2064 |
smtp_from_name: '',
|
| 2065 |
smtp_use_tls: true,
|
| 2066 |
+
email_provider: 'smtp',
|
| 2067 |
+
resend_api_key: '',
|
| 2068 |
+
resend_api_key_configured: false,
|
| 2069 |
+
resend_from_email: '',
|
| 2070 |
+
resend_from_name: '',
|
| 2071 |
// Cloudflare Turnstile
|
| 2072 |
turnstile_enabled: false,
|
| 2073 |
turnstile_site_key: '',
|
|
|
|
| 2247 |
)
|
| 2248 |
registrationEmailSuffixWhitelistDraft.value = ''
|
| 2249 |
form.smtp_password = ''
|
| 2250 |
+
form.resend_api_key = ''
|
| 2251 |
form.turnstile_secret_key = ''
|
| 2252 |
form.linuxdo_connect_client_secret = ''
|
| 2253 |
} catch (error: any) {
|
|
|
|
| 2347 |
smtp_from_email: form.smtp_from_email,
|
| 2348 |
smtp_from_name: form.smtp_from_name,
|
| 2349 |
smtp_use_tls: form.smtp_use_tls,
|
| 2350 |
+
email_provider: form.email_provider,
|
| 2351 |
+
resend_api_key: form.resend_api_key || undefined,
|
| 2352 |
+
resend_from_email: form.resend_from_email,
|
| 2353 |
+
resend_from_name: form.resend_from_name,
|
| 2354 |
turnstile_enabled: form.turnstile_enabled,
|
| 2355 |
turnstile_site_key: form.turnstile_site_key,
|
| 2356 |
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
|
|
|
| 2376 |
)
|
| 2377 |
registrationEmailSuffixWhitelistDraft.value = ''
|
| 2378 |
form.smtp_password = ''
|
| 2379 |
+
form.resend_api_key = ''
|
| 2380 |
form.turnstile_secret_key = ''
|
| 2381 |
form.linuxdo_connect_client_secret = ''
|
| 2382 |
// Refresh cached settings so sidebar/header update immediately
|
|
|
|
| 2413 |
}
|
| 2414 |
}
|
| 2415 |
|
| 2416 |
+
async function testResendConnection() {
|
| 2417 |
+
testingResend.value = true
|
| 2418 |
+
try {
|
| 2419 |
+
const result = await adminAPI.settings.testResendConnection({
|
| 2420 |
+
resend_api_key: form.resend_api_key
|
| 2421 |
+
})
|
| 2422 |
+
appStore.showSuccess(result.message || t('admin.settings.resend.connectionSuccess'))
|
| 2423 |
+
} catch (error: any) {
|
| 2424 |
+
appStore.showError(
|
| 2425 |
+
t('admin.settings.resend.connectionFailed') + ': ' + (error.message || t('common.unknownError'))
|
| 2426 |
+
)
|
| 2427 |
+
} finally {
|
| 2428 |
+
testingResend.value = false
|
| 2429 |
+
}
|
| 2430 |
+
}
|
| 2431 |
+
|
| 2432 |
async function sendTestEmail() {
|
| 2433 |
if (!testEmailAddress.value) {
|
| 2434 |
appStore.showError(t('admin.settings.testEmail.enterRecipientHint'))
|
|
|
|
| 2437 |
|
| 2438 |
sendingTestEmail.value = true
|
| 2439 |
try {
|
| 2440 |
+
const result = form.email_provider === 'resend'
|
| 2441 |
+
? await adminAPI.settings.sendResendTestEmail({
|
| 2442 |
+
email: testEmailAddress.value,
|
| 2443 |
+
resend_api_key: form.resend_api_key,
|
| 2444 |
+
resend_from_email: form.resend_from_email,
|
| 2445 |
+
resend_from_name: form.resend_from_name
|
| 2446 |
+
})
|
| 2447 |
+
: await adminAPI.settings.sendTestEmail({
|
| 2448 |
+
email: testEmailAddress.value,
|
| 2449 |
+
smtp_host: form.smtp_host,
|
| 2450 |
+
smtp_port: form.smtp_port,
|
| 2451 |
+
smtp_username: form.smtp_username,
|
| 2452 |
+
smtp_password: form.smtp_password,
|
| 2453 |
+
smtp_from_email: form.smtp_from_email,
|
| 2454 |
+
smtp_from_name: form.smtp_from_name,
|
| 2455 |
+
smtp_use_tls: form.smtp_use_tls
|
| 2456 |
+
})
|
| 2457 |
// API returns { message: "..." } on success, errors are thrown as exceptions
|
| 2458 |
+
appStore.showSuccess(
|
| 2459 |
+
result.message ||
|
| 2460 |
+
(form.email_provider === 'resend'
|
| 2461 |
+
? t('admin.settings.resend.testEmailSent')
|
| 2462 |
+
: t('admin.settings.testEmailSent'))
|
| 2463 |
+
)
|
| 2464 |
} catch (error: any) {
|
| 2465 |
appStore.showError(
|
| 2466 |
+
(form.email_provider === 'resend'
|
| 2467 |
+
? t('admin.settings.resend.testEmailFailed')
|
| 2468 |
+
: t('admin.settings.failedToSendTestEmail')) +
|
| 2469 |
+
': ' +
|
| 2470 |
+
(error.message || t('common.unknownError'))
|
| 2471 |
)
|
| 2472 |
} finally {
|
| 2473 |
sendingTestEmail.value = false
|