From 12f324688f5169a1411c0ec17ca1115c5d2a21a3 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 13:25:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=9C=A8=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=AD=E4=BF=9D=E7=95=99=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../internal/service/auth_email_binding.go | 4 +-- .../internal/service/auth_oauth_email_flow.go | 4 +-- backend/internal/service/auth_service.go | 16 ++++++------ .../internal/service/email_queue_service.go | 11 +++++--- backend/internal/service/totp_service.go | 4 +-- backend/internal/service/user_service.go | 26 ++++++++++++++++--- 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/backend/internal/service/auth_email_binding.go b/backend/internal/service/auth_email_binding.go index 78f1185d..84f61d78 100644 --- a/backend/internal/service/auth_email_binding.go +++ b/backend/internal/service/auth_email_binding.go @@ -94,7 +94,7 @@ func (s *AuthService) BindEmailIdentity( } // SendEmailIdentityBindCode sends a verification code for authenticated email binding flows. -func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string) error { +func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string, locale ...string) error { if s == nil { return ErrServiceUnavailable } @@ -128,7 +128,7 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } - return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName) + return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale)) } func normalizeEmailForIdentityBinding(email string) (string, error) { diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index 3478fda5..cf0be652 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -28,7 +28,7 @@ func normalizeOAuthSignupSource(signupSource string) string { // SendPendingOAuthVerifyCode sends a local verification code for pending OAuth // account-creation flows without relying on the public registration gate. -func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string) (*SendVerifyCodeResult, error) { +func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) { email = strings.TrimSpace(strings.ToLower(email)) if email == "" { return nil, ErrEmailVerifyRequired @@ -47,7 +47,7 @@ func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email stri if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } - if err := s.emailService.SendVerifyCode(ctx, email, siteName); err != nil { + if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil { return nil, err } return &SendVerifyCodeResult{ diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index ce2b3fa3..4e5b7b94 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -273,7 +273,7 @@ type SendVerifyCodeResult struct { } // SendVerifyCode 发送邮箱验证码(同步方式) -func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { +func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale ...string) error { // 检查是否开放注册(默认关闭) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return ErrRegDisabled @@ -307,11 +307,11 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { siteName = s.settingService.GetSiteName(ctx) } - return s.emailService.SendVerifyCode(ctx, email, siteName) + return s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)) } // SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时 -func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) { +func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) { logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email) // 检查是否开放注册(默认关闭) @@ -352,7 +352,7 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S // 异步发送 logger.LegacyPrintf("service.auth", "[Auth] Enqueueing verify code for: %s", email) - if err := s.emailQueueService.EnqueueVerifyCode(email, siteName); err != nil { + if err := s.emailQueueService.EnqueueVerifyCode(email, siteName, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err) return nil, fmt.Errorf("enqueue verify code: %w", err) } @@ -1251,7 +1251,7 @@ func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendB // RequestPasswordReset 请求密码重置(同步发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) -func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string) error { +func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } @@ -1264,7 +1264,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB return nil // Silent success to prevent enumeration } - if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err) return nil // Silent success to prevent enumeration } @@ -1275,7 +1275,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB // RequestPasswordResetAsync 异步请求密码重置(队列发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) -func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string) error { +func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } @@ -1288,7 +1288,7 @@ func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, fron return nil // Silent success to prevent enumeration } - if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil { + if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err) return nil // Silent success to prevent enumeration } diff --git a/backend/internal/service/email_queue_service.go b/backend/internal/service/email_queue_service.go index d8f0a518..a933e6bb 100644 --- a/backend/internal/service/email_queue_service.go +++ b/backend/internal/service/email_queue_service.go @@ -21,6 +21,7 @@ type EmailTask struct { SiteName string TaskType string // "verify_code" or "password_reset" ResetURL string // Only used for password_reset task type + Locale string // Optional Accept-Language locale hint } // EmailQueueService 异步邮件队列服务 @@ -82,13 +83,13 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { switch task.TaskType { case TaskTypeVerifyCode: - if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil { + if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName, task.Locale); err != nil { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err) } else { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email) } case TaskTypePasswordReset: - if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil { + if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL, task.Locale); err != nil { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err) } else { logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email) @@ -99,11 +100,12 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { } // EnqueueVerifyCode 将验证码发送任务加入队列 -func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { +func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string, locale ...string) error { task := EmailTask{ Email: email, SiteName: siteName, TaskType: TaskTypeVerifyCode, + Locale: firstEmailLocale(locale), } select { @@ -116,12 +118,13 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { } // EnqueuePasswordReset 将密码重置邮件任务加入队列 -func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error { +func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string, locale ...string) error { task := EmailTask{ Email: email, SiteName: siteName, TaskType: TaskTypePasswordReset, ResetURL: resetURL, + Locale: firstEmailLocale(locale), } select { diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go index 052739ed..6a0989c3 100644 --- a/backend/internal/service/totp_service.go +++ b/backend/internal/service/totp_service.go @@ -517,7 +517,7 @@ func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMe } // SendVerifyCode sends an email verification code for TOTP operations -func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error { +func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64, locale ...string) error { // Check if email verification is enabled if !s.settingService.IsEmailVerifyEnabled(ctx) { return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled") @@ -533,5 +533,5 @@ func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error { siteName := s.settingService.GetSiteName(ctx) // Send verification code via queue - return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName) + return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName, firstEmailLocale(locale)) } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b346f6e7..36bcf1c8 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -1121,7 +1121,7 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error { } // SendNotifyEmailCode sends a verification code to the extra notification email. -func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error { +func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache, locale ...string) error { if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil { return err } @@ -1133,7 +1133,7 @@ func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, ema // Send email first — if SMTP fails, don't write cache or increment counters, // so the user is not locked out by cooldown/rate-limit for a code they never received. - if err := s.sendNotifyVerifyEmail(ctx, emailService, email, code); err != nil { + if err := s.sendNotifyVerifyEmail(ctx, emailService, userID, email, code, firstEmailLocale(locale)); err != nil { return err } @@ -1179,13 +1179,33 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str } // sendNotifyVerifyEmail builds and sends the verification email. -func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, email, code string) error { +func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, userID int64, email, code, locale string) error { siteName := "Sub2API" if s.settingRepo != nil { if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" { siteName = name } } + if emailService.notificationEmailService != nil { + if err := emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventNotificationEmailVerifyCode, + Locale: locale, + RecipientEmail: email, + RecipientName: emailRecipientName(email), + UserID: userID, + Variables: map[string]string{ + "verification_code": code, + "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)), + }, + }); err == nil { + return nil + } else { + if !shouldFallbackNotificationEmail(err) { + return err + } + slog.Warn("template notification email verification failed; falling back to built-in body", "recipient_hash", notificationEmailHash(email), "err", err.Error()) + } + } subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName) body := buildNotifyVerifyEmailBody(code, siteName) return emailService.SendEmail(ctx, email, subject, body)