package service import ( "context" cryptorand "crypto/rand" "encoding/hex" "fmt" "log" "net/url" "net/smtp" "strings" "time" ) type EmailProvider interface { SendMail(ctx context.Context, to, subject, htmlBody string) error } type SMTPEmailConfig struct { Host string Port int Username string Password string FromEmail string FromName string TLS bool } type SMTPEmailProvider struct { cfg SMTPEmailConfig } func NewSMTPEmailProvider(cfg SMTPEmailConfig) EmailProvider { return &SMTPEmailProvider{cfg: cfg} } func (p *SMTPEmailProvider) SendMail(ctx context.Context, to, subject, htmlBody string) error { _ = ctx var authInfo smtp.Auth if p.cfg.Username != "" || p.cfg.Password != "" { authInfo = smtp.PlainAuth("", p.cfg.Username, p.cfg.Password, p.cfg.Host) } from := p.cfg.FromEmail if p.cfg.FromName != "" { from = fmt.Sprintf("%s <%s>", p.cfg.FromName, p.cfg.FromEmail) } headers := []string{ fmt.Sprintf("From: %s", from), fmt.Sprintf("To: %s", to), fmt.Sprintf("Subject: %s", subject), "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "", } message := strings.Join(headers, "\r\n") + htmlBody addr := fmt.Sprintf("%s:%d", p.cfg.Host, p.cfg.Port) return smtp.SendMail(addr, authInfo, p.cfg.FromEmail, []string{to}, []byte(message)) } type MockEmailProvider struct{} func (m *MockEmailProvider) SendMail(ctx context.Context, to, subject, htmlBody string) error { _ = ctx log.Printf("[email-mock] to=%s subject=%s body_bytes=%d", to, subject, len(htmlBody)) return nil } type EmailCodeConfig struct { CodeTTL time.Duration ResendCooldown time.Duration MaxDailyLimit int SiteURL string SiteName string } func DefaultEmailCodeConfig() EmailCodeConfig { return EmailCodeConfig{ CodeTTL: 5 * time.Minute, ResendCooldown: time.Minute, MaxDailyLimit: 10, SiteURL: "http://localhost:8080", SiteName: "User Management System", } } type EmailCodeService struct { provider EmailProvider cache cacheInterface cfg EmailCodeConfig } func NewEmailCodeService(provider EmailProvider, cache cacheInterface, cfg EmailCodeConfig) *EmailCodeService { if cfg.CodeTTL <= 0 { cfg.CodeTTL = 5 * time.Minute } if cfg.ResendCooldown <= 0 { cfg.ResendCooldown = time.Minute } if cfg.MaxDailyLimit <= 0 { cfg.MaxDailyLimit = 10 } return &EmailCodeService{ provider: provider, cache: cache, cfg: cfg, } } func (s *EmailCodeService) SendEmailCode(ctx context.Context, email, purpose string) error { cooldownKey := fmt.Sprintf("email_cooldown:%s:%s", purpose, email) if _, ok := s.cache.Get(ctx, cooldownKey); ok { return newRateLimitError(fmt.Sprintf("\u64cd\u4f5c\u8fc7\u4e8e\u9891\u7e41\uff0c\u8bf7 %d \u79d2\u540e\u518d\u8bd5", int(s.cfg.ResendCooldown.Seconds()))) } dailyKey := fmt.Sprintf("email_daily:%s:%s", email, time.Now().Format("2006-01-02")) var dailyCount int if value, ok := s.cache.Get(ctx, dailyKey); ok { if count, ok := intValue(value); ok { dailyCount = count } } if dailyCount >= s.cfg.MaxDailyLimit { return newRateLimitError("\u4eca\u65e5\u53d1\u9001\u6b21\u6570\u5df2\u8fbe\u4e0a\u9650\uff0c\u8bf7\u660e\u5929\u518d\u8bd5") } code, err := generateEmailCode() if err != nil { return err } codeKey := fmt.Sprintf("email_code:%s:%s", purpose, email) if err := s.cache.Set(ctx, codeKey, code, s.cfg.CodeTTL, s.cfg.CodeTTL); err != nil { return fmt.Errorf("store email code failed: %w", err) } if err := s.cache.Set(ctx, cooldownKey, true, s.cfg.ResendCooldown, s.cfg.ResendCooldown); err != nil { _ = s.cache.Delete(ctx, codeKey) return fmt.Errorf("store email cooldown failed: %w", err) } if err := s.cache.Set(ctx, dailyKey, dailyCount+1, 24*time.Hour, 24*time.Hour); err != nil { _ = s.cache.Delete(ctx, codeKey) _ = s.cache.Delete(ctx, cooldownKey) return fmt.Errorf("store email daily counter failed: %w", err) } subject, body := buildEmailCodeContent(purpose, code, s.cfg.SiteName, s.cfg.CodeTTL) if err := s.provider.SendMail(ctx, email, subject, body); err != nil { _ = s.cache.Delete(ctx, codeKey) _ = s.cache.Delete(ctx, cooldownKey) return fmt.Errorf("email delivery failed: %w", err) } return nil } func (s *EmailCodeService) VerifyEmailCode(ctx context.Context, email, purpose, code string) error { if strings.TrimSpace(code) == "" { return fmt.Errorf("verification code is required") } codeKey := fmt.Sprintf("email_code:%s:%s", purpose, email) value, ok := s.cache.Get(ctx, codeKey) if !ok { return fmt.Errorf("verification code expired or missing") } storedCode, ok := value.(string) if !ok || storedCode != code { return fmt.Errorf("verification code is invalid") } if err := s.cache.Delete(ctx, codeKey); err != nil { return fmt.Errorf("consume email code failed: %w", err) } return nil } type EmailActivationService struct { provider EmailProvider cache cacheInterface tokenTTL time.Duration siteURL string siteName string } func NewEmailActivationService(provider EmailProvider, cache cacheInterface, siteURL, siteName string) *EmailActivationService { return &EmailActivationService{ provider: provider, cache: cache, tokenTTL: 24 * time.Hour, siteURL: siteURL, siteName: siteName, } } func (s *EmailActivationService) SendActivationEmail(ctx context.Context, userID int64, email, username string) error { tokenBytes := make([]byte, 32) if _, err := cryptorand.Read(tokenBytes); err != nil { return fmt.Errorf("generate activation token failed: %w", err) } token := hex.EncodeToString(tokenBytes) cacheKey := fmt.Sprintf("email_activation:%s", token) if err := s.cache.Set(ctx, cacheKey, userID, s.tokenTTL, s.tokenTTL); err != nil { return fmt.Errorf("store activation token failed: %w", err) } activationURL := buildFrontendActivationURL(s.siteURL, token) subject := fmt.Sprintf("[%s] Activate Your Account", s.siteName) body := buildActivationEmailBody(username, activationURL, s.siteName, s.tokenTTL) return s.provider.SendMail(ctx, email, subject, body) } func buildFrontendActivationURL(siteURL, token string) string { base := strings.TrimRight(strings.TrimSpace(siteURL), "/") if base == "" { base = DefaultEmailCodeConfig().SiteURL } return fmt.Sprintf("%s/activate-account?token=%s", base, url.QueryEscape(token)) } func (s *EmailActivationService) ValidateActivationToken(ctx context.Context, token string) (int64, error) { token = strings.TrimSpace(token) if token == "" { return 0, fmt.Errorf("activation token is required") } cacheKey := fmt.Sprintf("email_activation:%s", token) value, ok := s.cache.Get(ctx, cacheKey) if !ok { return 0, fmt.Errorf("activation token expired or missing") } userID, ok := int64Value(value) if !ok { return 0, fmt.Errorf("activation token payload is invalid") } if err := s.cache.Delete(ctx, cacheKey); err != nil { return 0, fmt.Errorf("consume activation token failed: %w", err) } return userID, nil } func buildEmailCodeContent(purpose, code, siteName string, ttl time.Duration) (subject, body string) { purposeText := map[string]string{ "login": "login verification", "register": "registration verification", "reset": "password reset", "bind": "binding verification", } label := purposeText[purpose] if label == "" { label = "identity verification" } subject = fmt.Sprintf("[%s] Your %s code: %s", siteName, label, code) body = fmt.Sprintf(`

%s

Your %s code is:

%s

This code expires in %d minutes.

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

`, siteName, label, code, int(ttl.Minutes())) return subject, body } func buildActivationEmailBody(username, activationURL, siteName string, ttl time.Duration) string { return fmt.Sprintf(`

Welcome to %s

Hello %s,

Please click the button below to activate your account.

Activate Account

If the button does not work, copy this link into your browser:

%s

This link expires in %d hours.

`, siteName, username, activationURL, activationURL, int(ttl.Hours())) } func generateEmailCode() (string, error) { buffer := make([]byte, 3) if _, err := cryptorand.Read(buffer); err != nil { return "", fmt.Errorf("generate email code failed: %w", err) } value := int(buffer[0])<<16 | int(buffer[1])<<8 | int(buffer[2]) value = value % 1000000 if value < 100000 { value += 100000 } return fmt.Sprintf("%06d", value), nil }