package service import ( "context" "crypto/rand" "crypto/subtle" "crypto/tls" "encoding/hex" "fmt" "log" "math/big" "net/smtp" "net/url" "strconv" "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) var ( ErrEmailNotConfigured = infraerrors.ServiceUnavailable("EMAIL_NOT_CONFIGURED", "email service not configured") ErrInvalidVerifyCode = infraerrors.BadRequest("INVALID_VERIFY_CODE", "invalid or expired verification code") ErrVerifyCodeTooFrequent = infraerrors.TooManyRequests("VERIFY_CODE_TOO_FREQUENT", "please wait before requesting a new code") ErrVerifyCodeMaxAttempts = infraerrors.TooManyRequests("VERIFY_CODE_MAX_ATTEMPTS", "too many failed attempts, please request a new code") // Password reset errors ErrInvalidResetToken = infraerrors.BadRequest("INVALID_RESET_TOKEN", "invalid or expired password reset token") ) // EmailCache defines cache operations for email service type EmailCache interface { GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error) SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error DeleteVerificationCode(ctx context.Context, email string) error // Password reset token methods GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error DeletePasswordResetToken(ctx context.Context, email string) error // Password reset email cooldown methods // Returns true if in cooldown period (email was sent recently) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error } // VerificationCodeData represents verification code data type VerificationCodeData struct { Code string Attempts int CreatedAt time.Time } // PasswordResetTokenData represents password reset token data type PasswordResetTokenData struct { Token string CreatedAt time.Time } const ( verifyCodeTTL = 15 * time.Minute verifyCodeCooldown = 1 * time.Minute maxVerifyCodeAttempts = 5 // Password reset token settings passwordResetTokenTTL = 30 * time.Minute // Password reset email cooldown (prevent email bombing) passwordResetEmailCooldown = 30 * time.Second ) // SMTPConfig SMTP配置 type SMTPConfig struct { Host string Port int Username string Password string From string FromName string UseTLS bool } // EmailService 邮件服务 type EmailService struct { settingRepo SettingRepository cache EmailCache } // NewEmailService 创建邮件服务实例 func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailService { return &EmailService{ settingRepo: settingRepo, cache: cache, } } // GetSMTPConfig 从数据库获取SMTP配置 func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { keys := []string{ SettingKeySMTPHost, SettingKeySMTPPort, SettingKeySMTPUsername, SettingKeySMTPPassword, SettingKeySMTPFrom, SettingKeySMTPFromName, SettingKeySMTPUseTLS, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get smtp settings: %w", err) } host := settings[SettingKeySMTPHost] if host == "" { return nil, ErrEmailNotConfigured } port := 587 // 默认端口 if portStr := settings[SettingKeySMTPPort]; portStr != "" { if p, err := strconv.Atoi(portStr); err == nil { port = p } } useTLS := settings[SettingKeySMTPUseTLS] == "true" return &SMTPConfig{ Host: host, Port: port, Username: settings[SettingKeySMTPUsername], Password: settings[SettingKeySMTPPassword], From: settings[SettingKeySMTPFrom], FromName: settings[SettingKeySMTPFromName], UseTLS: useTLS, }, nil } // SendEmail 发送邮件(使用数据库中保存的配置) func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) error { config, err := s.GetSMTPConfig(ctx) if err != nil { return err } return s.SendEmailWithConfig(config, to, subject, body) } // SendEmailWithConfig 使用指定配置发送邮件 func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error { from := config.From if config.FromName != "" { from = fmt.Sprintf("%s <%s>", config.FromName, config.From) } msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", from, to, subject, body) addr := fmt.Sprintf("%s:%d", config.Host, config.Port) auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if config.UseTLS { return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host) } return smtp.SendMail(addr, auth, config.From, []string{to}, []byte(msg)) } // sendMailTLS 使用TLS发送邮件 func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { tlsConfig := &tls.Config{ ServerName: host, // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。 MinVersion: tls.VersionTLS12, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("tls dial: %w", err) } defer func() { _ = conn.Close() }() client, err := smtp.NewClient(conn, host) if err != nil { return fmt.Errorf("new smtp client: %w", err) } defer func() { _ = client.Close() }() if err = client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } if err = client.Mail(from); err != nil { return fmt.Errorf("smtp mail: %w", err) } if err = client.Rcpt(to); err != nil { return fmt.Errorf("smtp rcpt: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp data: %w", err) } _, err = w.Write(msg) if err != nil { return fmt.Errorf("write msg: %w", err) } err = w.Close() if err != nil { return fmt.Errorf("close writer: %w", err) } // Email is sent successfully after w.Close(), ignore Quit errors // Some SMTP servers return non-standard responses on QUIT _ = client.Quit() return nil } // GenerateVerifyCode 生成6位数字验证码 func (s *EmailService) GenerateVerifyCode() (string, error) { const digits = "0123456789" code := make([]byte, 6) for i := range code { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits)))) if err != nil { return "", err } code[i] = digits[num.Int64()] } return string(code), nil } // SendVerifyCode 发送验证码邮件 func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName string) error { // 检查是否在冷却期内 existing, err := s.cache.GetVerificationCode(ctx, email) if err == nil && existing != nil { if time.Since(existing.CreatedAt) < verifyCodeCooldown { return ErrVerifyCodeTooFrequent } } // 生成验证码 code, err := s.GenerateVerifyCode() if err != nil { return fmt.Errorf("generate code: %w", err) } // 保存验证码到 Redis data := &VerificationCodeData{ Code: code, Attempts: 0, CreatedAt: time.Now(), } if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { return fmt.Errorf("save verify code: %w", err) } // 构建邮件内容 subject := fmt.Sprintf("[%s] Email Verification Code", siteName) body := s.buildVerifyCodeEmailBody(code, siteName) // 发送邮件 if err := s.SendEmail(ctx, email, subject, body); err != nil { return fmt.Errorf("send email: %w", err) } return nil } // VerifyCode 验证验证码 func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error { data, err := s.cache.GetVerificationCode(ctx, email) if err != nil || data == nil { return ErrInvalidVerifyCode } // 检查是否已达到最大尝试次数 if data.Attempts >= maxVerifyCodeAttempts { return ErrVerifyCodeMaxAttempts } // 验证码不匹配 (constant-time comparison to prevent timing attacks) if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 { data.Attempts++ if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { log.Printf("[Email] Failed to update verification attempt count: %v", err) } if data.Attempts >= maxVerifyCodeAttempts { return ErrVerifyCodeMaxAttempts } return ErrInvalidVerifyCode } // 验证成功,删除验证码 if err := s.cache.DeleteVerificationCode(ctx, email); err != nil { log.Printf("[Email] Failed to delete verification code after success: %v", err) } return nil } // buildVerifyCodeEmailBody 构建验证码邮件HTML内容 func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string { return fmt.Sprintf(`

%s

Your verification code is:

%s

This code will expire in 15 minutes.

If you did not request this code, please ignore this email.

`, siteName, code) } // TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接 func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { addr := fmt.Sprintf("%s:%d", config.Host, config.Port) if config.UseTLS { tlsConfig := &tls.Config{ ServerName: config.Host, // 与发送逻辑一致,显式要求 TLS 1.2+。 MinVersion: tls.VersionTLS12, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("tls connection failed: %w", err) } defer func() { _ = conn.Close() }() client, err := smtp.NewClient(conn, config.Host) if err != nil { return fmt.Errorf("smtp client creation failed: %w", err) } defer func() { _ = client.Close() }() auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if err = client.Auth(auth); err != nil { return fmt.Errorf("smtp authentication failed: %w", err) } return client.Quit() } // 非TLS连接测试 client, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("smtp connection failed: %w", err) } defer func() { _ = client.Close() }() auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if err = client.Auth(auth); err != nil { return fmt.Errorf("smtp authentication failed: %w", err) } return client.Quit() } // GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters) func (s *EmailService) GeneratePasswordResetToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } // SendPasswordResetEmail sends a password reset email with a reset link func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error { var token string var needSaveToken bool // Check if token already exists existing, err := s.cache.GetPasswordResetToken(ctx, email) if err == nil && existing != nil { // Token exists, reuse it (allows resending email without generating new token) token = existing.Token needSaveToken = false } else { // Generate new token token, err = s.GeneratePasswordResetToken() if err != nil { return fmt.Errorf("generate token: %w", err) } needSaveToken = true } // Save token to Redis (only if new token generated) if needSaveToken { data := &PasswordResetTokenData{ Token: token, CreatedAt: time.Now(), } if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil { return fmt.Errorf("save reset token: %w", err) } } // Build full reset URL with URL-encoded token and email fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token)) // Build email content subject := fmt.Sprintf("[%s] 密码重置请求", siteName) body := s.buildPasswordResetEmailBody(fullResetURL, siteName) // Send email if err := s.SendEmail(ctx, email, subject, body); err != nil { return fmt.Errorf("send email: %w", err) } return nil } // SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker) // This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error { // Check email cooldown to prevent email bombing if s.cache.IsPasswordResetEmailInCooldown(ctx, email) { log.Printf("[Email] Password reset email skipped (cooldown): %s", email) return nil // Silent success to prevent revealing cooldown to attackers } // Send email using core method if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { return err } // Set cooldown marker (Redis TTL handles expiration) if err := s.cache.SetPasswordResetEmailCooldown(ctx, email, passwordResetEmailCooldown); err != nil { log.Printf("[Email] Failed to set password reset cooldown for %s: %v", email, err) } return nil } // VerifyPasswordResetToken verifies the password reset token without consuming it func (s *EmailService) VerifyPasswordResetToken(ctx context.Context, email, token string) error { data, err := s.cache.GetPasswordResetToken(ctx, email) if err != nil || data == nil { return ErrInvalidResetToken } // Use constant-time comparison to prevent timing attacks if subtle.ConstantTimeCompare([]byte(data.Token), []byte(token)) != 1 { return ErrInvalidResetToken } return nil } // ConsumePasswordResetToken verifies and deletes the token (one-time use) func (s *EmailService) ConsumePasswordResetToken(ctx context.Context, email, token string) error { // Verify first if err := s.VerifyPasswordResetToken(ctx, email, token); err != nil { return err } // Delete after verification (one-time use) if err := s.cache.DeletePasswordResetToken(ctx, email); err != nil { log.Printf("[Email] Failed to delete password reset token after consumption: %v", err) } return nil } // buildPasswordResetEmailBody builds the HTML content for password reset email func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) string { return fmt.Sprintf(`

%s

密码重置请求

您已请求重置密码。请点击下方按钮设置新密码:

重置密码

此链接将在 30 分钟后失效。

如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。

`, siteName, resetURL, resetURL) }