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

feat: replace Resend email provider with Mailjet

Browse files
backend/internal/handler/admin/setting_handler.go CHANGED
@@ -92,9 +92,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
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,10 +156,11 @@ type UpdateSettingsRequest struct {
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"`
@@ -249,8 +251,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
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 参数验证
@@ -494,9 +496,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
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,
@@ -595,9 +598,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
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,
@@ -702,14 +706,17 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
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")
@@ -911,35 +918,43 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
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 发送测试邮件请求
@@ -1050,56 +1065,64 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
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>
@@ -1125,8 +1148,8 @@ func (h *SettingHandler) SendResendTestEmail(c *gin.Context) {
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>
@@ -1136,12 +1159,12 @@ func (h *SettingHandler) SendResendTestEmail(c *gin.Context) {
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 状态
 
92
  SMTPFromName: settings.SMTPFromName,
93
  SMTPUseTLS: settings.SMTPUseTLS,
94
  EmailProvider: settings.EmailProvider,
95
+ MailjetAPIKeyConfigured: settings.MailjetAPIKeyConfigured,
96
+ MailjetSecretKeyConfigured: settings.MailjetSecretKeyConfigured,
97
+ MailjetFrom: settings.MailjetFrom,
98
+ MailjetFromName: settings.MailjetFromName,
99
  TurnstileEnabled: settings.TurnstileEnabled,
100
  TurnstileSiteKey: settings.TurnstileSiteKey,
101
  TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
 
156
  SMTPFrom string `json:"smtp_from_email"`
157
  SMTPFromName string `json:"smtp_from_name"`
158
  SMTPUseTLS bool `json:"smtp_use_tls"`
159
+ EmailProvider string `json:"email_provider" binding:"omitempty,oneof=smtp mailjet"`
160
+ MailjetAPIKey string `json:"mailjet_api_key"`
161
+ MailjetSecretKey string `json:"mailjet_secret_key"`
162
+ MailjetFrom string `json:"mailjet_from_email"`
163
+ MailjetFromName string `json:"mailjet_from_name"`
164
 
165
  // Cloudflare Turnstile 设置
166
  TurnstileEnabled bool `json:"turnstile_enabled"`
 
251
  req.EmailProvider = service.EmailProviderSMTP
252
  }
253
  }
254
+ req.MailjetFrom = strings.TrimSpace(req.MailjetFrom)
255
+ req.MailjetFromName = strings.TrimSpace(req.MailjetFromName)
256
  req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions)
257
 
258
  // Turnstile 参数验证
 
496
  SMTPFromName: req.SMTPFromName,
497
  SMTPUseTLS: req.SMTPUseTLS,
498
  EmailProvider: req.EmailProvider,
499
+ MailjetAPIKey: req.MailjetAPIKey,
500
+ MailjetSecretKey: req.MailjetSecretKey,
501
+ MailjetFrom: req.MailjetFrom,
502
+ MailjetFromName: req.MailjetFromName,
503
  TurnstileEnabled: req.TurnstileEnabled,
504
  TurnstileSiteKey: req.TurnstileSiteKey,
505
  TurnstileSecretKey: req.TurnstileSecretKey,
 
598
  SMTPFromName: updatedSettings.SMTPFromName,
599
  SMTPUseTLS: updatedSettings.SMTPUseTLS,
600
  EmailProvider: updatedSettings.EmailProvider,
601
+ MailjetAPIKeyConfigured: updatedSettings.MailjetAPIKeyConfigured,
602
+ MailjetSecretKeyConfigured: updatedSettings.MailjetSecretKeyConfigured,
603
+ MailjetFrom: updatedSettings.MailjetFrom,
604
+ MailjetFromName: updatedSettings.MailjetFromName,
605
  TurnstileEnabled: updatedSettings.TurnstileEnabled,
606
  TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
607
  TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
 
706
  if before.EmailProvider != after.EmailProvider {
707
  changed = append(changed, "email_provider")
708
  }
709
+ if req.MailjetAPIKey != "" {
710
+ changed = append(changed, "mailjet_api_key")
711
  }
712
+ if req.MailjetSecretKey != "" {
713
+ changed = append(changed, "mailjet_secret_key")
714
  }
715
+ if before.MailjetFrom != after.MailjetFrom {
716
+ changed = append(changed, "mailjet_from_email")
717
+ }
718
+ if before.MailjetFromName != after.MailjetFromName {
719
+ changed = append(changed, "mailjet_from_name")
720
  }
721
  if before.TurnstileEnabled != after.TurnstileEnabled {
722
  changed = append(changed, "turnstile_enabled")
 
918
  response.Success(c, gin.H{"message": "SMTP connection successful"})
919
  }
920
 
921
+ type TestMailjetRequest struct {
922
+ MailjetAPIKey string `json:"mailjet_api_key"`
923
+ MailjetSecretKey string `json:"mailjet_secret_key"`
924
  }
925
 
926
+ // TestMailjetConnection tests Mailjet API connectivity.
927
+ // POST /api/v1/admin/settings/test-mailjet
928
+ func (h *SettingHandler) TestMailjetConnection(c *gin.Context) {
929
+ var req TestMailjetRequest
930
  if err := c.ShouldBindJSON(&req); err != nil {
931
  response.BadRequest(c, "Invalid request: "+err.Error())
932
  return
933
  }
934
 
935
+ apiKey := strings.TrimSpace(req.MailjetAPIKey)
936
+ secretKey := strings.TrimSpace(req.MailjetSecretKey)
937
+ if apiKey == "" || secretKey == "" {
938
+ savedConfig, err := h.emailService.GetMailjetConfig(c.Request.Context())
939
  if err == nil && savedConfig != nil {
940
+ if apiKey == "" {
941
+ apiKey = savedConfig.APIKey
942
+ }
943
+ if secretKey == "" {
944
+ secretKey = savedConfig.SecretKey
945
+ }
946
  }
947
  }
948
 
949
+ if err := h.emailService.TestMailjetConnectionWithConfig(c.Request.Context(), &service.MailjetConfig{
950
+ APIKey: apiKey,
951
+ SecretKey: secretKey,
952
  }); err != nil {
953
+ response.BadRequest(c, "Mailjet connection test failed: "+err.Error())
954
  return
955
  }
956
 
957
+ response.Success(c, gin.H{"message": "Mailjet connection successful"})
958
  }
959
 
960
  // SendTestEmailRequest 发送测试邮件请求
 
1065
  response.Success(c, gin.H{"message": "Test email sent successfully"})
1066
  }
1067
 
1068
+ type SendMailjetTestEmailRequest struct {
1069
+ Email string `json:"email" binding:"required,email"`
1070
+ MailjetAPIKey string `json:"mailjet_api_key"`
1071
+ MailjetSecretKey string `json:"mailjet_secret_key"`
1072
+ MailjetFrom string `json:"mailjet_from_email" binding:"omitempty,email"`
1073
+ MailjetFromName string `json:"mailjet_from_name"`
1074
  }
1075
 
1076
+ // SendMailjetTestEmail sends a test email using Mailjet.
1077
+ // POST /api/v1/admin/settings/send-test-email-mailjet
1078
+ func (h *SettingHandler) SendMailjetTestEmail(c *gin.Context) {
1079
+ var req SendMailjetTestEmailRequest
1080
  if err := c.ShouldBindJSON(&req); err != nil {
1081
  response.BadRequest(c, "Invalid request: "+err.Error())
1082
  return
1083
  }
1084
 
1085
+ apiKey := strings.TrimSpace(req.MailjetAPIKey)
1086
+ secretKey := strings.TrimSpace(req.MailjetSecretKey)
1087
+ var savedConfig *service.MailjetConfig
1088
+ if apiKey == "" || secretKey == "" {
1089
  var err error
1090
+ savedConfig, err = h.emailService.GetMailjetConfig(c.Request.Context())
1091
  if err == nil && savedConfig != nil {
1092
+ if apiKey == "" {
1093
+ apiKey = savedConfig.APIKey
1094
+ }
1095
+ if secretKey == "" {
1096
+ secretKey = savedConfig.SecretKey
1097
+ }
1098
  }
1099
  }
1100
+ if savedConfig == nil && (strings.TrimSpace(req.MailjetFrom) == "" || strings.TrimSpace(req.MailjetFromName) == "") {
1101
  var err error
1102
+ savedConfig, err = h.emailService.GetMailjetConfig(c.Request.Context())
1103
  if err != nil {
1104
  savedConfig = nil
1105
  }
1106
  }
1107
 
1108
+ from := strings.TrimSpace(req.MailjetFrom)
1109
  if from == "" && savedConfig != nil {
1110
  from = savedConfig.From
1111
  }
1112
+ fromName := strings.TrimSpace(req.MailjetFromName)
1113
  if fromName == "" && savedConfig != nil {
1114
  fromName = savedConfig.FromName
1115
  }
1116
 
1117
+ config := &service.MailjetConfig{
1118
+ APIKey: apiKey,
1119
+ SecretKey: secretKey,
1120
+ From: from,
1121
+ FromName: fromName,
1122
  }
1123
 
1124
  siteName := h.settingService.GetSiteName(c.Request.Context())
1125
+ subject := "[" + siteName + "] Mailjet Test Email"
1126
  body := `
1127
  <!DOCTYPE html>
1128
  <html>
 
1148
  <div class="success">✓</div>
1149
  -->
1150
  <div class="success-ok">OK</div>
1151
+ <h2>Mailjet API Configuration Successful!</h2>
1152
+ <p>This is a test email sent through the Mailjet API.</p>
1153
  </div>
1154
  <div class="footer">
1155
  <p>This is an automated test message.</p>
 
1159
  </html>
1160
  `
1161
 
1162
+ if err := h.emailService.SendEmailWithMailjetConfig(c.Request.Context(), config, req.Email, subject, body); err != nil {
1163
+ response.BadRequest(c, "Failed to send Mailjet test email: "+err.Error())
1164
  return
1165
  }
1166
 
1167
+ response.Success(c, gin.H{"message": "Mailjet test email sent successfully"})
1168
  }
1169
 
1170
  // GetAdminAPIKey 获取管理员 API Key 状态
backend/internal/handler/dto/settings.go CHANGED
@@ -34,10 +34,11 @@ 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
- 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"`
 
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
+ MailjetAPIKeyConfigured bool `json:"mailjet_api_key_configured"`
39
+ MailjetSecretKeyConfigured bool `json:"mailjet_secret_key_configured"`
40
+ MailjetFrom string `json:"mailjet_from_email"`
41
+ MailjetFromName string `json:"mailjet_from_name"`
42
 
43
  TurnstileEnabled bool `json:"turnstile_enabled"`
44
  TurnstileSiteKey string `json:"turnstile_site_key"`
backend/internal/server/routes/admin.go CHANGED
@@ -398,9 +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("/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)
 
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-mailjet", h.Admin.Setting.TestMailjetConnection)
402
  adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
403
+ adminSettings.POST("/send-test-email-mailjet", h.Admin.Setting.SendMailjetTestEmail)
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
@@ -2,7 +2,7 @@ package service
2
 
3
  import "github.com/Wei-Shaw/sub2api/internal/domain"
4
 
5
- // Status constants
6
  const (
7
  StatusActive = domain.StatusActive
8
  StatusDisabled = domain.StatusDisabled
@@ -12,13 +12,13 @@ const (
12
  StatusExpired = domain.StatusExpired
13
  )
14
 
15
- // Role constants
16
  const (
17
  RoleAdmin = domain.RoleAdmin
18
  RoleUser = domain.RoleUser
19
  )
20
 
21
- // Platform constants
22
  const (
23
  PlatformAnthropic = domain.PlatformAnthropic
24
  PlatformOpenAI = domain.PlatformOpenAI
@@ -27,16 +27,16 @@ const (
27
  PlatformSora = domain.PlatformSora
28
  )
29
 
30
- // Account type constants
31
  const (
32
- AccountTypeOAuth = domain.AccountTypeOAuth // OAuth类型账号(full scope: profile + inference)
33
- AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope)
34
- AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
35
- AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
36
- AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
37
  )
38
 
39
- // Redeem type constants
40
  const (
41
  RedeemTypeBalance = domain.RedeemTypeBalance
42
  RedeemTypeConcurrency = domain.RedeemTypeConcurrency
@@ -44,201 +44,129 @@ const (
44
  RedeemTypeInvitation = domain.RedeemTypeInvitation
45
  )
46
 
47
- // PromoCode status constants
48
  const (
49
  PromoCodeStatusActive = domain.PromoCodeStatusActive
50
  PromoCodeStatusDisabled = domain.PromoCodeStatusDisabled
51
  )
52
 
53
- // Admin adjustment type constants
54
  const (
55
- AdjustmentTypeAdminBalance = domain.AdjustmentTypeAdminBalance // 管理员调整余额
56
- AdjustmentTypeAdminConcurrency = domain.AdjustmentTypeAdminConcurrency // 管理员调整并发数
57
  )
58
 
59
- // Group subscription type constants
60
  const (
61
- SubscriptionTypeStandard = domain.SubscriptionTypeStandard // 标准计费模式(按余额扣费)
62
- SubscriptionTypeSubscription = domain.SubscriptionTypeSubscription // 订阅模式(按限额控制)
63
  )
64
 
65
- // Subscription status constants
66
  const (
67
  SubscriptionStatusActive = domain.SubscriptionStatusActive
68
  SubscriptionStatusExpired = domain.SubscriptionStatusExpired
69
  SubscriptionStatusSuspended = domain.SubscriptionStatusSuspended
70
  )
71
 
72
- // LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
73
  const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
74
 
75
- // Setting keys
76
  const (
77
- // 注册设置
78
- SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
79
- SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
80
- SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
81
- SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
82
- SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
83
- SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
84
- SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
85
-
86
- // 邮件服务设置
87
- SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
88
- SettingKeySMTPPort = "smtp_port" // SMTP端口
89
- SettingKeySMTPUsername = "smtp_username" // SMTP用户名
90
- SettingKeySMTPPassword = "smtp_password" // SMTP密码(加密存储)
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 验证
101
- SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key
102
- SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key
103
-
104
- // TOTP 双因素认证设置
105
- SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能
106
-
107
- // LinuxDo Connect OAuth 登录设置
108
  SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
109
  SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
110
  SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
111
  SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
112
 
113
- // OEM设置
114
- SettingKeySoraClientEnabled = "sora_client_enabled" // 是否启用 Sora 客户端(管理员手动控制)
115
- SettingKeySiteName = "site_name" // 网站名称
116
- SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
117
- SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
118
- SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
119
- SettingKeyContactInfo = "contact_info" // 客服联系方式
120
- SettingKeyDocURL = "doc_url" // 文档链接
121
- SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
122
- SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
123
- SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
124
- SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
125
- SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组)
126
-
127
- // 默认配置
128
- SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
129
- SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
130
- SettingKeyDefaultSubscriptions = "default_subscriptions" // 新用户默认订阅列表(JSON)
131
-
132
- // 管理员 API Key
133
- SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成)
134
-
135
- // Gemini 配额策略(JSON)
136
- SettingKeyGeminiQuotaPolicy = "gemini_quota_policy"
137
-
138
- // Model fallback settings
139
  SettingKeyEnableModelFallback = "enable_model_fallback"
140
  SettingKeyFallbackModelAnthropic = "fallback_model_anthropic"
141
  SettingKeyFallbackModelOpenAI = "fallback_model_openai"
142
  SettingKeyFallbackModelGemini = "fallback_model_gemini"
143
  SettingKeyFallbackModelAntigravity = "fallback_model_antigravity"
144
 
145
- // Request identity patch (Claude -> Gemini systemInstruction injection)
146
  SettingKeyEnableIdentityPatch = "enable_identity_patch"
147
  SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
148
 
149
- // =========================
150
- // Ops Monitoring (vNext)
151
- // =========================
152
-
153
- // SettingKeyOpsMonitoringEnabled is a DB-backed soft switch to enable/disable ops module at runtime.
154
- SettingKeyOpsMonitoringEnabled = "ops_monitoring_enabled"
155
-
156
- // SettingKeyOpsRealtimeMonitoringEnabled controls realtime features (e.g. WS/QPS push).
157
  SettingKeyOpsRealtimeMonitoringEnabled = "ops_realtime_monitoring_enabled"
 
 
 
 
 
 
158
 
159
- // SettingKeyOpsQueryModeDefault controls the default query mode for ops dashboard (auto/raw/preagg).
160
- SettingKeyOpsQueryModeDefault = "ops_query_mode_default"
161
-
162
- // SettingKeyOpsEmailNotificationConfig stores JSON config for ops email notifications.
163
- SettingKeyOpsEmailNotificationConfig = "ops_email_notification_config"
164
-
165
- // SettingKeyOpsAlertRuntimeSettings stores JSON config for ops alert evaluator runtime settings.
166
- SettingKeyOpsAlertRuntimeSettings = "ops_alert_runtime_settings"
167
-
168
- // SettingKeyOpsMetricsIntervalSeconds controls the ops metrics collector interval (>=60).
169
- SettingKeyOpsMetricsIntervalSeconds = "ops_metrics_interval_seconds"
170
-
171
- // SettingKeyOpsAdvancedSettings stores JSON config for ops advanced settings (data retention, aggregation).
172
- SettingKeyOpsAdvancedSettings = "ops_advanced_settings"
173
-
174
- // SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings.
175
- SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config"
176
-
177
- // =========================
178
- // Overload Cooldown (529)
179
- // =========================
180
-
181
- // SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling.
182
  SettingKeyOverloadCooldownSettings = "overload_cooldown_settings"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
- // =========================
185
- // Stream Timeout Handling
186
- // =========================
187
-
188
- // SettingKeyStreamTimeoutSettings stores JSON config for stream timeout handling.
189
- SettingKeyStreamTimeoutSettings = "stream_timeout_settings"
190
-
191
- // =========================
192
- // Request Rectifier (请求整流器)
193
- // =========================
194
-
195
- // SettingKeyRectifierSettings stores JSON config for rectifier settings (thinking signature + budget).
196
- SettingKeyRectifierSettings = "rectifier_settings"
197
-
198
- // =========================
199
- // Beta Policy Settings
200
- // =========================
201
-
202
- // SettingKeyBetaPolicySettings stores JSON config for beta policy rules.
203
- SettingKeyBetaPolicySettings = "beta_policy_settings"
204
-
205
- // =========================
206
- // Sora S3 存储配置
207
- // =========================
208
-
209
- SettingKeySoraS3Enabled = "sora_s3_enabled" // 是否启用 Sora S3 存储
210
- SettingKeySoraS3Endpoint = "sora_s3_endpoint" // S3 端点地址
211
- SettingKeySoraS3Region = "sora_s3_region" // S3 区域
212
- SettingKeySoraS3Bucket = "sora_s3_bucket" // S3 存储桶名称
213
- SettingKeySoraS3AccessKeyID = "sora_s3_access_key_id" // S3 Access Key ID
214
- SettingKeySoraS3SecretAccessKey = "sora_s3_secret_access_key" // S3 Secret Access Key(加密存储)
215
- SettingKeySoraS3Prefix = "sora_s3_prefix" // S3 对象键前缀
216
- SettingKeySoraS3ForcePathStyle = "sora_s3_force_path_style" // 是否强制 Path Style(兼容 MinIO 等)
217
- SettingKeySoraS3CDNURL = "sora_s3_cdn_url" // CDN 加速 URL(可选)
218
- SettingKeySoraS3Profiles = "sora_s3_profiles" // Sora S3 多配置(JSON)
219
-
220
- // =========================
221
- // Sora 用户存储配额
222
- // =========================
223
-
224
- SettingKeySoraDefaultStorageQuotaBytes = "sora_default_storage_quota_bytes" // 新用户默认 Sora 存储配额(字节)
225
-
226
- // =========================
227
- // Claude Code Version Check
228
- // =========================
229
-
230
- // SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
231
  SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
232
-
233
- // SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查)
234
  SettingKeyMaxClaudeCodeVersion = "max_claude_code_version"
235
 
236
- // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403)
237
  SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"
238
-
239
- // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
240
- SettingKeyBackendModeEnabled = "backend_mode_enabled"
241
  )
242
 
243
- // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
244
  const AdminAPIKeyPrefix = "admin-"
 
2
 
3
  import "github.com/Wei-Shaw/sub2api/internal/domain"
4
 
5
+ // Status constants.
6
  const (
7
  StatusActive = domain.StatusActive
8
  StatusDisabled = domain.StatusDisabled
 
12
  StatusExpired = domain.StatusExpired
13
  )
14
 
15
+ // Role constants.
16
  const (
17
  RoleAdmin = domain.RoleAdmin
18
  RoleUser = domain.RoleUser
19
  )
20
 
21
+ // Platform constants.
22
  const (
23
  PlatformAnthropic = domain.PlatformAnthropic
24
  PlatformOpenAI = domain.PlatformOpenAI
 
27
  PlatformSora = domain.PlatformSora
28
  )
29
 
30
+ // Account type constants.
31
  const (
32
+ AccountTypeOAuth = domain.AccountTypeOAuth
33
+ AccountTypeSetupToken = domain.AccountTypeSetupToken
34
+ AccountTypeAPIKey = domain.AccountTypeAPIKey
35
+ AccountTypeUpstream = domain.AccountTypeUpstream
36
+ AccountTypeBedrock = domain.AccountTypeBedrock
37
  )
38
 
39
+ // Redeem type constants.
40
  const (
41
  RedeemTypeBalance = domain.RedeemTypeBalance
42
  RedeemTypeConcurrency = domain.RedeemTypeConcurrency
 
44
  RedeemTypeInvitation = domain.RedeemTypeInvitation
45
  )
46
 
47
+ // Promo code status constants.
48
  const (
49
  PromoCodeStatusActive = domain.PromoCodeStatusActive
50
  PromoCodeStatusDisabled = domain.PromoCodeStatusDisabled
51
  )
52
 
53
+ // Admin adjustment type constants.
54
  const (
55
+ AdjustmentTypeAdminBalance = domain.AdjustmentTypeAdminBalance
56
+ AdjustmentTypeAdminConcurrency = domain.AdjustmentTypeAdminConcurrency
57
  )
58
 
59
+ // Group subscription type constants.
60
  const (
61
+ SubscriptionTypeStandard = domain.SubscriptionTypeStandard
62
+ SubscriptionTypeSubscription = domain.SubscriptionTypeSubscription
63
  )
64
 
65
+ // Subscription status constants.
66
  const (
67
  SubscriptionStatusActive = domain.SubscriptionStatusActive
68
  SubscriptionStatusExpired = domain.SubscriptionStatusExpired
69
  SubscriptionStatusSuspended = domain.SubscriptionStatusSuspended
70
  )
71
 
 
72
  const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
73
 
74
+ // Setting keys.
75
  const (
76
+ SettingKeyRegistrationEnabled = "registration_enabled"
77
+ SettingKeyEmailVerifyEnabled = "email_verify_enabled"
78
+ SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist"
79
+ SettingKeyPromoCodeEnabled = "promo_code_enabled"
80
+ SettingKeyPasswordResetEnabled = "password_reset_enabled"
81
+ SettingKeyFrontendURL = "frontend_url"
82
+ SettingKeyInvitationCodeEnabled = "invitation_code_enabled"
83
+
84
+ SettingKeySMTPHost = "smtp_host"
85
+ SettingKeySMTPPort = "smtp_port"
86
+ SettingKeySMTPUsername = "smtp_username"
87
+ SettingKeySMTPPassword = "smtp_password"
88
+ SettingKeySMTPFrom = "smtp_from"
89
+ SettingKeySMTPFromName = "smtp_from_name"
90
+ SettingKeySMTPUseTLS = "smtp_use_tls"
91
+
92
+ SettingKeyEmailProvider = "email_provider"
93
+ SettingKeyMailjetAPIKey = "mailjet_api_key"
94
+ SettingKeyMailjetSecretKey = "mailjet_secret_key"
95
+ SettingKeyMailjetFrom = "mailjet_from"
96
+ SettingKeyMailjetFromName = "mailjet_from_name"
97
+
98
+ SettingKeyTurnstileEnabled = "turnstile_enabled"
99
+ SettingKeyTurnstileSiteKey = "turnstile_site_key"
100
+ SettingKeyTurnstileSecretKey = "turnstile_secret_key"
101
+
102
+ SettingKeyTotpEnabled = "totp_enabled"
103
+
 
 
 
104
  SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
105
  SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
106
  SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
107
  SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
108
 
109
+ SettingKeySoraClientEnabled = "sora_client_enabled"
110
+ SettingKeySiteName = "site_name"
111
+ SettingKeySiteLogo = "site_logo"
112
+ SettingKeySiteSubtitle = "site_subtitle"
113
+ SettingKeyAPIBaseURL = "api_base_url"
114
+ SettingKeyContactInfo = "contact_info"
115
+ SettingKeyDocURL = "doc_url"
116
+ SettingKeyHomeContent = "home_content"
117
+ SettingKeyHideCcsImportButton = "hide_ccs_import_button"
118
+ SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled"
119
+ SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url"
120
+ SettingKeyCustomMenuItems = "custom_menu_items"
121
+
122
+ SettingKeyDefaultConcurrency = "default_concurrency"
123
+ SettingKeyDefaultBalance = "default_balance"
124
+ SettingKeyDefaultSubscriptions = "default_subscriptions"
125
+ SettingKeyAdminAPIKey = "admin_api_key"
126
+ SettingKeyGeminiQuotaPolicy = "gemini_quota_policy"
127
+
 
 
 
 
 
 
 
128
  SettingKeyEnableModelFallback = "enable_model_fallback"
129
  SettingKeyFallbackModelAnthropic = "fallback_model_anthropic"
130
  SettingKeyFallbackModelOpenAI = "fallback_model_openai"
131
  SettingKeyFallbackModelGemini = "fallback_model_gemini"
132
  SettingKeyFallbackModelAntigravity = "fallback_model_antigravity"
133
 
 
134
  SettingKeyEnableIdentityPatch = "enable_identity_patch"
135
  SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
136
 
137
+ SettingKeyOpsMonitoringEnabled = "ops_monitoring_enabled"
 
 
 
 
 
 
 
138
  SettingKeyOpsRealtimeMonitoringEnabled = "ops_realtime_monitoring_enabled"
139
+ SettingKeyOpsQueryModeDefault = "ops_query_mode_default"
140
+ SettingKeyOpsEmailNotificationConfig = "ops_email_notification_config"
141
+ SettingKeyOpsAlertRuntimeSettings = "ops_alert_runtime_settings"
142
+ SettingKeyOpsMetricsIntervalSeconds = "ops_metrics_interval_seconds"
143
+ SettingKeyOpsAdvancedSettings = "ops_advanced_settings"
144
+ SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config"
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  SettingKeyOverloadCooldownSettings = "overload_cooldown_settings"
147
+ SettingKeyStreamTimeoutSettings = "stream_timeout_settings"
148
+ SettingKeyRectifierSettings = "rectifier_settings"
149
+ SettingKeyBetaPolicySettings = "beta_policy_settings"
150
+
151
+ SettingKeySoraS3Enabled = "sora_s3_enabled"
152
+ SettingKeySoraS3Endpoint = "sora_s3_endpoint"
153
+ SettingKeySoraS3Region = "sora_s3_region"
154
+ SettingKeySoraS3Bucket = "sora_s3_bucket"
155
+ SettingKeySoraS3AccessKeyID = "sora_s3_access_key_id"
156
+ SettingKeySoraS3SecretAccessKey = "sora_s3_secret_access_key"
157
+ SettingKeySoraS3Prefix = "sora_s3_prefix"
158
+ SettingKeySoraS3ForcePathStyle = "sora_s3_force_path_style"
159
+ SettingKeySoraS3CDNURL = "sora_s3_cdn_url"
160
+ SettingKeySoraS3Profiles = "sora_s3_profiles"
161
+
162
+ SettingKeySoraDefaultStorageQuotaBytes = "sora_default_storage_quota_bytes"
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
 
 
165
  SettingKeyMaxClaudeCodeVersion = "max_claude_code_version"
166
 
 
167
  SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"
168
+ SettingKeyBackendModeEnabled = "backend_mode_enabled"
 
 
169
  )
170
 
171
+ // AdminAPIKeyPrefix is the prefix for admin API keys.
172
  const AdminAPIKeyPrefix = "admin-"
backend/internal/service/email_service.go CHANGED
@@ -74,10 +74,10 @@ const (
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,10 +91,11 @@ type SMTPConfig struct {
91
  UseTLS bool
92
  }
93
 
94
- type ResendConfig struct {
95
- APIKey string
96
- From string
97
- FromName string
 
98
  }
99
 
100
  // EmailService 邮件服务
@@ -102,7 +103,7 @@ type EmailService struct {
102
  settingRepo SettingRepository
103
  cache EmailCache
104
  httpClient *http.Client
105
- resendAPIBaseURL string
106
  }
107
 
108
  // NewEmailService 创建邮件服务实例
@@ -111,7 +112,7 @@ func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailServ
111
  settingRepo: settingRepo,
112
  cache: cache,
113
  httpClient: &http.Client{Timeout: 15 * time.Second},
114
- resendAPIBaseURL: defaultResendAPIBaseURL,
115
  }
116
  }
117
 
@@ -157,24 +158,26 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
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
@@ -189,8 +192,8 @@ func (s *EmailService) GetEmailProvider(ctx context.Context) (string, error) {
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
  }
@@ -201,12 +204,12 @@ func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string)
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)
@@ -236,38 +239,61 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body
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
  }
@@ -517,25 +543,25 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
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
  }
 
74
  // Password reset email cooldown (prevent email bombing)
75
  passwordResetEmailCooldown = 30 * time.Second
76
 
77
+ EmailProviderSMTP = "smtp"
78
+ EmailProviderMailjet = "mailjet"
79
 
80
+ defaultMailjetAPIBaseURL = "https://api.mailjet.com"
81
  )
82
 
83
  // SMTPConfig SMTP配置
 
91
  UseTLS bool
92
  }
93
 
94
+ type MailjetConfig struct {
95
+ APIKey string
96
+ SecretKey string
97
+ From string
98
+ FromName string
99
  }
100
 
101
  // EmailService 邮件服务
 
103
  settingRepo SettingRepository
104
  cache EmailCache
105
  httpClient *http.Client
106
+ mailjetAPIBaseURL string
107
  }
108
 
109
  // NewEmailService 创建邮件服务实例
 
112
  settingRepo: settingRepo,
113
  cache: cache,
114
  httpClient: &http.Client{Timeout: 15 * time.Second},
115
+ mailjetAPIBaseURL: defaultMailjetAPIBaseURL,
116
  }
117
  }
118
 
 
158
  }, nil
159
  }
160
 
161
+ func (s *EmailService) GetMailjetConfig(ctx context.Context) (*MailjetConfig, error) {
162
  keys := []string{
163
+ SettingKeyMailjetAPIKey,
164
+ SettingKeyMailjetSecretKey,
165
+ SettingKeyMailjetFrom,
166
+ SettingKeyMailjetFromName,
167
  }
168
 
169
  settings, err := s.settingRepo.GetMultiple(ctx, keys)
170
  if err != nil {
171
+ return nil, fmt.Errorf("get mailjet settings: %w", err)
172
  }
173
 
174
+ config := &MailjetConfig{
175
+ APIKey: strings.TrimSpace(settings[SettingKeyMailjetAPIKey]),
176
+ SecretKey: strings.TrimSpace(settings[SettingKeyMailjetSecretKey]),
177
+ From: strings.TrimSpace(settings[SettingKeyMailjetFrom]),
178
+ FromName: strings.TrimSpace(settings[SettingKeyMailjetFromName]),
179
  }
180
+ if config.APIKey == "" || config.SecretKey == "" || config.From == "" {
181
  return nil, ErrEmailNotConfigured
182
  }
183
  return config, nil
 
192
  return "", fmt.Errorf("get email provider: %w", err)
193
  }
194
  provider = strings.TrimSpace(provider)
195
+ if provider == EmailProviderMailjet {
196
+ return EmailProviderMailjet, nil
197
  }
198
  return EmailProviderSMTP, nil
199
  }
 
204
  if err != nil {
205
  return err
206
  }
207
+ if provider == EmailProviderMailjet {
208
+ config, configErr := s.GetMailjetConfig(ctx)
209
  if configErr != nil {
210
  return configErr
211
  }
212
+ return s.SendEmailWithMailjetConfig(ctx, config, to, subject, body)
213
  }
214
 
215
  config, configErr := s.GetSMTPConfig(ctx)
 
239
  return smtp.SendMail(addr, auth, config.From, []string{to}, []byte(msg))
240
  }
241
 
242
+ func (s *EmailService) SendEmailWithMailjetConfig(ctx context.Context, config *MailjetConfig, to, subject, body string) error {
243
+ if config == nil || strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.SecretKey) == "" || strings.TrimSpace(config.From) == "" {
244
  return ErrEmailNotConfigured
245
  }
246
 
247
+ type mailjetContact struct {
248
+ Email string `json:"Email"`
249
+ Name string `json:"Name,omitempty"`
250
+ }
251
+
252
+ type mailjetMessage struct {
253
+ From mailjetContact `json:"From"`
254
+ To []mailjetContact `json:"To"`
255
+ Subject string `json:"Subject"`
256
+ HTMLPart string `json:"HTMLPart"`
257
+ }
258
+
259
+ payload := struct {
260
+ Messages []mailjetMessage `json:"Messages"`
261
+ }{
262
+ Messages: []mailjetMessage{
263
+ {
264
+ From: mailjetContact{
265
+ Email: config.From,
266
+ Name: strings.TrimSpace(config.FromName),
267
+ },
268
+ To: []mailjetContact{
269
+ {Email: to},
270
+ },
271
+ Subject: subject,
272
+ HTMLPart: body,
273
+ },
274
+ },
275
  }
276
 
277
  bodyBytes, err := json.Marshal(payload)
278
  if err != nil {
279
+ return fmt.Errorf("marshal mailjet payload: %w", err)
280
  }
281
 
282
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.mailjetAPIBaseURL+"/v3.1/send", bytes.NewReader(bodyBytes))
283
  if err != nil {
284
+ return fmt.Errorf("build mailjet request: %w", err)
285
  }
286
+ req.SetBasicAuth(config.APIKey, config.SecretKey)
287
  req.Header.Set("Content-Type", "application/json")
288
 
289
  resp, err := s.httpClient.Do(req)
290
  if err != nil {
291
+ return fmt.Errorf("mailjet request failed: %w", err)
292
  }
293
  defer func() { _ = resp.Body.Close() }()
294
 
295
  if resp.StatusCode < 200 || resp.StatusCode >= 300 {
296
+ return fmt.Errorf("mailjet send failed: %s", readEmailAPIError(resp))
297
  }
298
  return nil
299
  }
 
543
  return client.Quit()
544
  }
545
 
546
+ func (s *EmailService) TestMailjetConnectionWithConfig(ctx context.Context, config *MailjetConfig) error {
547
+ if config == nil || strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.SecretKey) == "" {
548
  return ErrEmailNotConfigured
549
  }
550
 
551
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.mailjetAPIBaseURL+"/v3/REST/sender", nil)
552
  if err != nil {
553
+ return fmt.Errorf("build mailjet request: %w", err)
554
  }
555
+ req.SetBasicAuth(config.APIKey, config.SecretKey)
556
 
557
  resp, err := s.httpClient.Do(req)
558
  if err != nil {
559
+ return fmt.Errorf("mailjet request failed: %w", err)
560
  }
561
  defer func() { _ = resp.Body.Close() }()
562
 
563
  if resp.StatusCode < 200 || resp.StatusCode >= 300 {
564
+ return fmt.Errorf("mailjet connection failed: %s", readEmailAPIError(resp))
565
  }
566
  return nil
567
  }
backend/internal/service/email_service_resend_test.go CHANGED
@@ -4,14 +4,23 @@ 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
  }
@@ -53,56 +62,75 @@ 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
@@ -119,18 +147,19 @@ func TestEmailService_TestResendConnectionWithConfig(t *testing.T) {
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"}`))
@@ -139,12 +168,13 @@ func TestEmailService_SendEmailWithResendConfig_ReportsAPIError(t *testing.T) {
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)
 
4
 
5
  import (
6
  "context"
7
+ "encoding/base64"
8
  "encoding/json"
9
+ "io"
10
  "net/http"
11
  "net/http/httptest"
12
+ "strings"
13
  "testing"
14
 
15
  "github.com/stretchr/testify/require"
16
  )
17
 
18
+ type roundTripFunc func(*http.Request) (*http.Response, error)
19
+
20
+ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
21
+ return fn(req)
22
+ }
23
+
24
  type emailServiceRepoStub struct {
25
  settings map[string]string
26
  }
 
62
  panic("unexpected Delete call")
63
  }
64
 
65
+ func TestEmailService_SendEmail_UsesMailjetProvider(t *testing.T) {
66
+ type mailjetRecipient struct {
67
+ Email string `json:"Email"`
68
+ }
69
+
70
+ type mailjetSender struct {
71
+ Email string `json:"Email"`
72
+ Name string `json:"Name"`
73
+ }
74
+
75
+ type mailjetMessage struct {
76
+ From mailjetSender `json:"From"`
77
+ To []mailjetRecipient `json:"To"`
78
+ Subject string `json:"Subject"`
79
+ HTMLPart string `json:"HTMLPart"`
80
+ }
81
+
82
+ type mailjetPayload struct {
83
+ Messages []mailjetMessage `json:"Messages"`
84
  }
85
 
86
  var (
87
  gotMethod string
88
  gotPath string
89
  gotAuth string
90
+ gotPayload mailjetPayload
91
  )
92
 
 
 
 
 
 
 
 
 
 
 
93
  repo := &emailServiceRepoStub{
94
  settings: map[string]string{
95
+ SettingKeyEmailProvider: EmailProviderMailjet,
96
+ SettingKeyMailjetAPIKey: "mj_api_key",
97
+ SettingKeyMailjetSecretKey: "mj_secret_key",
98
+ SettingKeyMailjetFrom: "noreply@example.com",
99
+ SettingKeyMailjetFromName: "XAPI",
100
  },
101
  }
102
 
103
  svc := NewEmailService(repo, nil)
104
+ svc.httpClient = &http.Client{
105
+ Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
106
+ gotMethod = r.Method
107
+ gotPath = r.URL.Path
108
+ gotAuth = r.Header.Get("Authorization")
109
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&gotPayload))
110
+ return &http.Response{
111
+ StatusCode: http.StatusOK,
112
+ Status: "200 OK",
113
+ Header: make(http.Header),
114
+ Body: io.NopCloser(strings.NewReader(`{"Messages":[{"Status":"success"}]}`)),
115
+ Request: r,
116
+ }, nil
117
+ }),
118
+ }
119
 
120
  err := svc.SendEmail(context.Background(), "user@example.com", "Hello", "<p>World</p>")
121
  require.NoError(t, err)
122
  require.Equal(t, http.MethodPost, gotMethod)
123
+ require.Equal(t, "/v3.1/send", gotPath)
124
+ require.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("mj_api_key:mj_secret_key")), gotAuth)
125
+ require.Len(t, gotPayload.Messages, 1)
126
+ require.Equal(t, "noreply@example.com", gotPayload.Messages[0].From.Email)
127
+ require.Equal(t, "XAPI", gotPayload.Messages[0].From.Name)
128
+ require.Equal(t, []mailjetRecipient{{Email: "user@example.com"}}, gotPayload.Messages[0].To)
129
+ require.Equal(t, "Hello", gotPayload.Messages[0].Subject)
130
+ require.Equal(t, "<p>World</p>", gotPayload.Messages[0].HTMLPart)
131
  }
132
 
133
+ func TestEmailService_TestMailjetConnectionWithConfig(t *testing.T) {
134
  var (
135
  gotMethod string
136
  gotPath string
 
147
 
148
  svc := NewEmailService(&emailServiceRepoStub{}, nil)
149
  svc.httpClient = server.Client()
150
+ svc.mailjetAPIBaseURL = server.URL
151
 
152
+ err := svc.TestMailjetConnectionWithConfig(context.Background(), &MailjetConfig{
153
+ APIKey: "mj_api_key",
154
+ SecretKey: "mj_secret_key",
155
  })
156
  require.NoError(t, err)
157
  require.Equal(t, http.MethodGet, gotMethod)
158
+ require.Equal(t, "/v3/REST/sender", gotPath)
159
+ require.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("mj_api_key:mj_secret_key")), gotAuth)
160
  }
161
 
162
+ func TestEmailService_SendEmailWithMailjetConfig_ReportsAPIError(t *testing.T) {
163
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164
  w.WriteHeader(http.StatusBadRequest)
165
  _, _ = w.Write([]byte(`{"message":"invalid from address"}`))
 
168
 
169
  svc := NewEmailService(&emailServiceRepoStub{}, nil)
170
  svc.httpClient = server.Client()
171
+ svc.mailjetAPIBaseURL = server.URL
172
 
173
+ err := svc.SendEmailWithMailjetConfig(context.Background(), &MailjetConfig{
174
+ APIKey: "mj_api_key",
175
+ SecretKey: "mj_secret_key",
176
+ From: "noreply@example.com",
177
+ FromName: "XAPI",
178
  }, "user@example.com", "Hello", "<p>World</p>")
179
 
180
  require.Error(t, err)
backend/internal/service/setting_service.go CHANGED
@@ -429,11 +429,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
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)
@@ -755,9 +758,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
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",
@@ -804,9 +808,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
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,9 +853,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
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
 
 
429
  updates[SettingKeySMTPFromName] = settings.SMTPFromName
430
  updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
431
  updates[SettingKeyEmailProvider] = settings.EmailProvider
432
+ if settings.MailjetAPIKey != "" {
433
+ updates[SettingKeyMailjetAPIKey] = settings.MailjetAPIKey
434
  }
435
+ if settings.MailjetSecretKey != "" {
436
+ updates[SettingKeyMailjetSecretKey] = settings.MailjetSecretKey
437
+ }
438
+ updates[SettingKeyMailjetFrom] = settings.MailjetFrom
439
+ updates[SettingKeyMailjetFromName] = settings.MailjetFromName
440
 
441
  // Cloudflare Turnstile 设置(只有非空才更新密钥)
442
  updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
 
758
  SettingKeySMTPPort: "587",
759
  SettingKeySMTPUseTLS: "false",
760
  SettingKeyEmailProvider: EmailProviderSMTP,
761
+ SettingKeyMailjetAPIKey: "",
762
+ SettingKeyMailjetSecretKey: "",
763
+ SettingKeyMailjetFrom: "",
764
+ SettingKeyMailjetFromName: "",
765
  // Model fallback defaults
766
  SettingKeyEnableModelFallback: "false",
767
  SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
 
808
  SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
809
  SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
810
  EmailProvider: s.getStringOrDefault(settings, SettingKeyEmailProvider, EmailProviderSMTP),
811
+ MailjetFrom: settings[SettingKeyMailjetFrom],
812
+ MailjetFromName: settings[SettingKeyMailjetFromName],
813
+ MailjetAPIKeyConfigured: settings[SettingKeyMailjetAPIKey] != "",
814
+ MailjetSecretKeyConfigured: settings[SettingKeyMailjetSecretKey] != "",
815
  TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
816
  TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
817
  TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
 
853
 
854
  // 敏感信息直接返回,方便测试连接时使用
855
  result.SMTPPassword = settings[SettingKeySMTPPassword]
856
+ result.MailjetAPIKey = settings[SettingKeyMailjetAPIKey]
857
+ result.MailjetSecretKey = settings[SettingKeyMailjetSecretKey]
858
  result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
859
+ if result.EmailProvider != EmailProviderSMTP && result.EmailProvider != EmailProviderMailjet {
860
  result.EmailProvider = EmailProviderSMTP
861
  }
862
 
backend/internal/service/setting_service_update_test.go CHANGED
@@ -194,39 +194,44 @@ func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(
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) {
 
194
  require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
195
  }
196
 
197
+ func TestSettingService_UpdateSettings_PersistsMailjetSettings(t *testing.T) {
198
  repo := &settingUpdateRepoStub{}
199
  svc := NewSettingService(repo, &config.Config{})
200
 
201
  err := svc.UpdateSettings(context.Background(), &SystemSettings{
202
+ EmailProvider: EmailProviderMailjet,
203
+ MailjetAPIKey: "mj_api_key",
204
+ MailjetSecretKey: "mj_secret_key",
205
+ MailjetFrom: "noreply@example.com",
206
+ MailjetFromName: "XAPI",
207
  })
208
 
209
  require.NoError(t, err)
210
+ require.Equal(t, EmailProviderMailjet, repo.updates[SettingKeyEmailProvider])
211
+ require.Equal(t, "mj_api_key", repo.updates[SettingKeyMailjetAPIKey])
212
+ require.Equal(t, "mj_secret_key", repo.updates[SettingKeyMailjetSecretKey])
213
+ require.Equal(t, "noreply@example.com", repo.updates[SettingKeyMailjetFrom])
214
+ require.Equal(t, "XAPI", repo.updates[SettingKeyMailjetFromName])
215
  }
216
 
217
+ func TestSettingService_ParseSettings_ExposesMailjetState(t *testing.T) {
218
  svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{})
219
 
220
  settings := svc.parseSettings(map[string]string{
221
+ SettingKeyEmailProvider: EmailProviderMailjet,
222
+ SettingKeyMailjetAPIKey: "mj_api_key",
223
+ SettingKeyMailjetSecretKey: "mj_secret_key",
224
+ SettingKeyMailjetFrom: "noreply@example.com",
225
+ SettingKeyMailjetFromName: "XAPI",
226
  })
227
 
228
+ require.Equal(t, EmailProviderMailjet, settings.EmailProvider)
229
+ require.True(t, settings.MailjetAPIKeyConfigured)
230
+ require.True(t, settings.MailjetSecretKeyConfigured)
231
+ require.Equal(t, "mj_api_key", settings.MailjetAPIKey)
232
+ require.Equal(t, "mj_secret_key", settings.MailjetSecretKey)
233
+ require.Equal(t, "noreply@example.com", settings.MailjetFrom)
234
+ require.Equal(t, "XAPI", settings.MailjetFromName)
235
  }
236
 
237
  func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
backend/internal/service/settings_view.go CHANGED
@@ -18,11 +18,13 @@ type SystemSettings struct {
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
 
18
  SMTPFrom string
19
  SMTPFromName string
20
  SMTPUseTLS bool
21
+ EmailProvider string
22
+ MailjetAPIKey string
23
+ MailjetSecretKey string
24
+ MailjetAPIKeyConfigured bool
25
+ MailjetSecretKeyConfigured bool
26
+ MailjetFrom string
27
+ MailjetFromName string
28
 
29
  TurnstileEnabled bool
30
  TurnstileSiteKey string
frontend/src/api/admin/settings.ts CHANGED
@@ -51,10 +51,11 @@ export interface SystemSettings {
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,10 +124,11 @@ export interface UpdateSettingsRequest {
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,8 +182,9 @@ export interface TestSmtpRequest {
180
  smtp_use_tls: boolean
181
  }
182
 
183
- export interface TestResendRequest {
184
- resend_api_key: string
 
185
  }
186
 
187
  /**
@@ -194,8 +197,8 @@ export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ mes
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
 
@@ -213,11 +216,12 @@ export interface SendTestEmailRequest {
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
  /**
@@ -233,11 +237,11 @@ export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ me
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
@@ -558,9 +562,9 @@ export const settingsAPI = {
558
  getSettings,
559
  updateSettings,
560
  testSmtpConnection,
561
- testResendConnection,
562
  sendTestEmail,
563
- sendResendTestEmail,
564
  getAdminApiKey,
565
  regenerateAdminApiKey,
566
  deleteAdminApiKey,
 
51
  smtp_from_email: string
52
  smtp_from_name: string
53
  smtp_use_tls: boolean
54
+ email_provider: 'smtp' | 'mailjet' | string
55
+ mailjet_api_key_configured: boolean
56
+ mailjet_secret_key_configured: boolean
57
+ mailjet_from_email: string
58
+ mailjet_from_name: string
59
  // Cloudflare Turnstile settings
60
  turnstile_enabled: boolean
61
  turnstile_site_key: string
 
124
  smtp_from_email?: string
125
  smtp_from_name?: string
126
  smtp_use_tls?: boolean
127
+ email_provider?: 'smtp' | 'mailjet' | string
128
+ mailjet_api_key?: string
129
+ mailjet_secret_key?: string
130
+ mailjet_from_email?: string
131
+ mailjet_from_name?: string
132
  turnstile_enabled?: boolean
133
  turnstile_site_key?: string
134
  turnstile_secret_key?: string
 
182
  smtp_use_tls: boolean
183
  }
184
 
185
+ export interface TestMailjetRequest {
186
+ mailjet_api_key: string
187
+ mailjet_secret_key: string
188
  }
189
 
190
  /**
 
197
  return data
198
  }
199
 
200
+ export async function testMailjetConnection(config: TestMailjetRequest): Promise<{ message: string }> {
201
+ const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-mailjet', config)
202
  return data
203
  }
204
 
 
216
  smtp_use_tls: boolean
217
  }
218
 
219
+ export interface SendMailjetTestEmailRequest {
220
  email: string
221
+ mailjet_api_key: string
222
+ mailjet_secret_key: string
223
+ mailjet_from_email: string
224
+ mailjet_from_name: string
225
  }
226
 
227
  /**
 
237
  return data
238
  }
239
 
240
+ export async function sendMailjetTestEmail(
241
+ request: SendMailjetTestEmailRequest
242
  ): Promise<{ message: string }> {
243
  const { data } = await apiClient.post<{ message: string }>(
244
+ '/admin/settings/send-test-email-mailjet',
245
  request
246
  )
247
  return data
 
562
  getSettings,
563
  updateSettings,
564
  testSmtpConnection,
565
+ testMailjetConnection,
566
  sendTestEmail,
567
+ sendMailjetTestEmail,
568
  getAdminApiKey,
569
  regenerateAdminApiKey,
570
  deleteAdminApiKey,
frontend/src/i18n/locales/en.ts CHANGED
@@ -4245,33 +4245,38 @@ export default {
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',
 
4245
  useTls: 'Use TLS',
4246
  useTlsHint: 'Enable TLS encryption for SMTP connection'
4247
  },
4248
+ mailjet: {
4249
+ title: 'Email Verification API Settings',
4250
+ description: 'Configure Mailjet API delivery for verification codes, password reset links, and other system emails',
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
+ providerMailjet: 'Mailjet API',
4257
+ apiKey: 'Mailjet API Key',
4258
+ apiKeyPlaceholder: 'api_key...',
4259
  apiKeyHint: 'Leave empty to keep the existing API key',
4260
+ apiKeyConfiguredPlaceholder: '********************************',
4261
  apiKeyConfiguredHint: 'API key configured. Leave empty to keep the current value.',
4262
+ secretKey: 'Mailjet Secret Key',
4263
+ secretKeyPlaceholder: 'secret_key...',
4264
+ secretKeyHint: 'Leave empty to keep the existing secret key',
4265
+ secretKeyConfiguredPlaceholder: '********************************',
4266
+ secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.',
4267
+ fromEmail: 'Mailjet From Email',
4268
+ fromEmailPlaceholder: "noreply{'@'}example.com",
4269
+ fromName: 'Mailjet From Name',
4270
  fromNamePlaceholder: 'Sub2API',
4271
+ connectionSuccess: 'Mailjet API connection successful',
4272
+ connectionFailed: 'Mailjet API connection test failed',
4273
+ testEmailSent: 'Mailjet test email sent successfully',
4274
+ testEmailFailed: 'Failed to send Mailjet test email'
4275
  },
4276
  testEmail: {
4277
  title: 'Send Test Email',
4278
  description: 'Send a test email to verify your SMTP configuration',
4279
+ descriptionMailjet: 'Send a test email using the currently selected Mailjet API configuration',
4280
  recipientEmail: 'Recipient Email',
4281
  recipientEmailPlaceholder: "test{'@'}example.com",
4282
  sendTestEmail: 'Send Test Email',
frontend/src/i18n/locales/zh.ts CHANGED
@@ -4410,33 +4410,38 @@ export default {
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: '发送测试邮件',
 
4410
  useTls: '使用 TLS',
4411
  useTlsHint: '为 SMTP 连接启用 TLS 加密'
4412
  },
4413
+ mailjet: {
4414
+ title: '邮箱验证 API 设置',
4415
+ description: '配置 Mailjet API,用于送验证码、重置密码链接等系统邮件',
4416
  testConnection: '测试 API 连接',
4417
  testing: '测试中...',
4418
  provider: '当前邮件提供商',
4419
  providerHint: '验证码、重置密码等系统邮件会使用这里选择的提供商发送',
4420
  providerSmtp: 'SMTP',
4421
+ providerMailjet: 'Mailjet API',
4422
+ apiKey: 'Mailjet API Key',
4423
+ apiKeyPlaceholder: 'api_key...',
4424
  apiKeyHint: '留空则保留当前 API Key',
4425
+ apiKeyConfiguredPlaceholder: '********************************',
4426
  apiKeyConfiguredHint: 'API Key 已配置,留空则保留当前值。',
4427
+ secretKey: 'Mailjet Secret Key',
4428
+ secretKeyPlaceholder: 'secret_key...',
4429
+ secretKeyHint: '留空则保留当前 Secret Key',
4430
+ secretKeyConfiguredPlaceholder: '********************************',
4431
+ secretKeyConfiguredHint: 'Secret Key 已配置,留空则保留当前值。',
4432
+ fromEmail: 'Mailjet 发件邮箱',
4433
+ fromEmailPlaceholder: "noreply{'@'}example.com",
4434
+ fromName: 'Mailjet 发件人名称',
4435
  fromNamePlaceholder: 'Sub2API',
4436
+ connectionSuccess: 'Mailjet API 连接成功',
4437
+ connectionFailed: 'Mailjet API 连接测试失败',
4438
+ testEmailSent: 'Mailjet 测试邮件发送成功',
4439
+ testEmailFailed: 'Mailjet 测试邮件发送失败'
4440
  },
4441
  testEmail: {
4442
  title: '发送测试邮件',
4443
  description: '发送测试邮件以验证 SMTP 配置',
4444
+ descriptionMailjet: '使用当前选择的 Mailjet API 配置发送测试邮件',
4445
  recipientEmail: '收件人邮箱',
4446
  recipientEmailPlaceholder: "test{'@'}example.com",
4447
  sendTestEmail: '发送测试邮件',
frontend/src/views/admin/SettingsView.vue CHANGED
@@ -1712,19 +1712,19 @@
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"
@@ -1740,69 +1740,91 @@
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>
@@ -1817,8 +1839,8 @@
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>
@@ -1958,7 +1980,7 @@ const { copyToClipboard } = useClipboard()
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,7 +2046,8 @@ interface DefaultSubscriptionGroupOption {
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
  }
@@ -2064,10 +2087,12 @@ const form = reactive<SettingsForm>({
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,7 +2272,8 @@ async function loadSettings() {
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) {
@@ -2348,9 +2374,10 @@ async function saveSettings() {
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,7 +2403,8 @@ async function saveSettings() {
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,19 +2441,20 @@ async function testSmtpConnection() {
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
 
@@ -2437,12 +2466,13 @@ async function sendTestEmail() {
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,
@@ -2457,14 +2487,14 @@ async function sendTestEmail() {
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'))
 
1712
  >
1713
  <div>
1714
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white">
1715
+ {{ t('admin.settings.mailjet.title') }}
1716
  </h2>
1717
  <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
1718
+ {{ t('admin.settings.mailjet.description') }}
1719
  </p>
1720
  </div>
1721
  <button
1722
  type="button"
1723
+ @click="testMailjetConnection"
1724
+ :disabled="testingMailjet"
1725
  class="btn btn-secondary btn-sm"
1726
  >
1727
+ <svg v-if="testingMailjet" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
1728
  <circle
1729
  class="opacity-25"
1730
  cx="12"
 
1740
  ></path>
1741
  </svg>
1742
  {{
1743
+ testingMailjet
1744
+ ? t('admin.settings.mailjet.testing')
1745
+ : t('admin.settings.mailjet.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.mailjet.provider') }}
1753
  </label>
1754
  <select v-model="form.email_provider" class="input">
1755
+ <option value="smtp">{{ t('admin.settings.mailjet.providerSmtp') }}</option>
1756
+ <option value="mailjet">{{ t('admin.settings.mailjet.providerMailjet') }}</option>
1757
  </select>
1758
  <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
1759
+ {{ t('admin.settings.mailjet.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.mailjet.apiKey') }}
1767
  </label>
1768
  <input
1769
+ v-model="form.mailjet_api_key"
1770
  type="password"
1771
  class="input"
1772
  :placeholder="
1773
+ form.mailjet_api_key_configured
1774
+ ? t('admin.settings.mailjet.apiKeyConfiguredPlaceholder')
1775
+ : t('admin.settings.mailjet.apiKeyPlaceholder')
1776
  "
1777
  />
1778
  <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
1779
  {{
1780
+ form.mailjet_api_key_configured
1781
+ ? t('admin.settings.mailjet.apiKeyConfiguredHint')
1782
+ : t('admin.settings.mailjet.apiKeyHint')
1783
+ }}
1784
+ </p>
1785
+ </div>
1786
+ <div class="md:col-span-2">
1787
+ <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
1788
+ {{ t('admin.settings.mailjet.secretKey') }}
1789
+ </label>
1790
+ <input
1791
+ v-model="form.mailjet_secret_key"
1792
+ type="password"
1793
+ class="input"
1794
+ :placeholder="
1795
+ form.mailjet_secret_key_configured
1796
+ ? t('admin.settings.mailjet.secretKeyConfiguredPlaceholder')
1797
+ : t('admin.settings.mailjet.secretKeyPlaceholder')
1798
+ "
1799
+ />
1800
+ <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
1801
+ {{
1802
+ form.mailjet_secret_key_configured
1803
+ ? t('admin.settings.mailjet.secretKeyConfiguredHint')
1804
+ : t('admin.settings.mailjet.secretKeyHint')
1805
  }}
1806
  </p>
1807
  </div>
1808
  <div>
1809
  <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
1810
+ {{ t('admin.settings.mailjet.fromEmail') }}
1811
  </label>
1812
  <input
1813
+ v-model="form.mailjet_from_email"
1814
  type="email"
1815
  class="input"
1816
+ :placeholder="t('admin.settings.mailjet.fromEmailPlaceholder')"
1817
  />
1818
  </div>
1819
  <div>
1820
  <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
1821
+ {{ t('admin.settings.mailjet.fromName') }}
1822
  </label>
1823
  <input
1824
+ v-model="form.mailjet_from_name"
1825
  type="text"
1826
  class="input"
1827
+ :placeholder="t('admin.settings.mailjet.fromNamePlaceholder')"
1828
  />
1829
  </div>
1830
  </div>
 
1839
  </h2>
1840
  <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
1841
  {{
1842
+ form.email_provider === 'mailjet'
1843
+ ? t('admin.settings.testEmail.descriptionMailjet')
1844
  : t('admin.settings.testEmail.description')
1845
  }}
1846
  </p>
 
1980
  const loading = ref(true)
1981
  const saving = ref(false)
1982
  const testingSmtp = ref(false)
1983
+ const testingMailjet = ref(false)
1984
  const sendingTestEmail = ref(false)
1985
  const testEmailAddress = ref('')
1986
  const registrationEmailSuffixWhitelistTags = ref<string[]>([])
 
2046
 
2047
  type SettingsForm = SystemSettings & {
2048
  smtp_password: string
2049
+ mailjet_api_key: string
2050
+ mailjet_secret_key: string
2051
  turnstile_secret_key: string
2052
  linuxdo_connect_client_secret: string
2053
  }
 
2087
  smtp_from_name: '',
2088
  smtp_use_tls: true,
2089
  email_provider: 'smtp',
2090
+ mailjet_api_key: '',
2091
+ mailjet_secret_key: '',
2092
+ mailjet_api_key_configured: false,
2093
+ mailjet_secret_key_configured: false,
2094
+ mailjet_from_email: '',
2095
+ mailjet_from_name: '',
2096
  // Cloudflare Turnstile
2097
  turnstile_enabled: false,
2098
  turnstile_site_key: '',
 
2272
  )
2273
  registrationEmailSuffixWhitelistDraft.value = ''
2274
  form.smtp_password = ''
2275
+ form.mailjet_api_key = ''
2276
+ form.mailjet_secret_key = ''
2277
  form.turnstile_secret_key = ''
2278
  form.linuxdo_connect_client_secret = ''
2279
  } catch (error: any) {
 
2374
  smtp_from_name: form.smtp_from_name,
2375
  smtp_use_tls: form.smtp_use_tls,
2376
  email_provider: form.email_provider,
2377
+ mailjet_api_key: form.mailjet_api_key || undefined,
2378
+ mailjet_secret_key: form.mailjet_secret_key || undefined,
2379
+ mailjet_from_email: form.mailjet_from_email,
2380
+ mailjet_from_name: form.mailjet_from_name,
2381
  turnstile_enabled: form.turnstile_enabled,
2382
  turnstile_site_key: form.turnstile_site_key,
2383
  turnstile_secret_key: form.turnstile_secret_key || undefined,
 
2403
  )
2404
  registrationEmailSuffixWhitelistDraft.value = ''
2405
  form.smtp_password = ''
2406
+ form.mailjet_api_key = ''
2407
+ form.mailjet_secret_key = ''
2408
  form.turnstile_secret_key = ''
2409
  form.linuxdo_connect_client_secret = ''
2410
  // Refresh cached settings so sidebar/header update immediately
 
2441
  }
2442
  }
2443
 
2444
+ async function testMailjetConnection() {
2445
+ testingMailjet.value = true
2446
  try {
2447
+ const result = await adminAPI.settings.testMailjetConnection({
2448
+ mailjet_api_key: form.mailjet_api_key,
2449
+ mailjet_secret_key: form.mailjet_secret_key
2450
  })
2451
+ appStore.showSuccess(result.message || t('admin.settings.mailjet.connectionSuccess'))
2452
  } catch (error: any) {
2453
  appStore.showError(
2454
+ t('admin.settings.mailjet.connectionFailed') + ': ' + (error.message || t('common.unknownError'))
2455
  )
2456
  } finally {
2457
+ testingMailjet.value = false
2458
  }
2459
  }
2460
 
 
2466
 
2467
  sendingTestEmail.value = true
2468
  try {
2469
+ const result = form.email_provider === 'mailjet'
2470
+ ? await adminAPI.settings.sendMailjetTestEmail({
2471
  email: testEmailAddress.value,
2472
+ mailjet_api_key: form.mailjet_api_key,
2473
+ mailjet_secret_key: form.mailjet_secret_key,
2474
+ mailjet_from_email: form.mailjet_from_email,
2475
+ mailjet_from_name: form.mailjet_from_name
2476
  })
2477
  : await adminAPI.settings.sendTestEmail({
2478
  email: testEmailAddress.value,
 
2487
  // API returns { message: "..." } on success, errors are thrown as exceptions
2488
  appStore.showSuccess(
2489
  result.message ||
2490
+ (form.email_provider === 'mailjet'
2491
+ ? t('admin.settings.mailjet.testEmailSent')
2492
  : t('admin.settings.testEmailSent'))
2493
  )
2494
  } catch (error: any) {
2495
  appStore.showError(
2496
+ (form.email_provider === 'mailjet'
2497
+ ? t('admin.settings.mailjet.testEmailFailed')
2498
  : t('admin.settings.failedToSendTestEmail')) +
2499
  ': ' +
2500
  (error.message || t('common.unknownError'))