cjovs commited on
Commit
6868917
·
1 Parent(s): adbb485

Add Resend email provider and default login redirect

Browse files
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
- savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context())
 
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: req.SMTPFrom,
917
- FromName: req.SMTPFromName,
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 { color: #10b981; font-size: 48px; margin-bottom: 20px; }
 
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 SettingRepository
86
- cache EmailCache
 
 
87
  }
88
 
89
  // NewEmailService 创建邮件服务实例
90
  func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailService {
91
  return &EmailService{
92
- settingRepo: settingRepo,
93
- cache: 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
- config, err := s.GetSMTPConfig(ctx)
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
- updates[SettingKeySMTPPassword] = settings.SMTPPassword
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: '/home'
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
- {{ t('admin.settings.testEmail.description') }}
 
 
 
 
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 = await adminAPI.settings.sendTestEmail({
2305
- email: testEmailAddress.value,
2306
- smtp_host: form.smtp_host,
2307
- smtp_port: form.smtp_port,
2308
- smtp_username: form.smtp_username,
2309
- smtp_password: form.smtp_password,
2310
- smtp_from_email: form.smtp_from_email,
2311
- smtp_from_name: form.smtp_from_name,
2312
- smtp_use_tls: form.smtp_use_tls
2313
- })
 
 
 
 
 
 
 
2314
  // API returns { message: "..." } on success, errors are thrown as exceptions
2315
- appStore.showSuccess(result.message || t('admin.settings.testEmailSent'))
 
 
 
 
 
2316
  } catch (error: any) {
2317
  appStore.showError(
2318
- t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError'))
 
 
 
 
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