Files
user-system/internal/service/email.go

309 lines
9.2 KiB
Go

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(`<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#333;">%s</h2>
<p>Your %s code is:</p>
<div style="background:#f5f5f5;padding:20px;text-align:center;margin:20px 0;border-radius:8px;">
<span style="font-size:36px;font-weight:bold;color:#2563eb;letter-spacing:8px;">%s</span>
</div>
<p>This code expires in <strong>%d minutes</strong>.</p>
<p style="color:#999;font-size:12px;">If you did not request this code, you can ignore this email.</p>
</body>
</html>`, siteName, label, code, int(ttl.Minutes()))
return subject, body
}
func buildActivationEmailBody(username, activationURL, siteName string, ttl time.Duration) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#333;">Welcome to %s</h2>
<p>Hello <strong>%s</strong>,</p>
<p>Please click the button below to activate your account.</p>
<div style="text-align:center;margin:30px 0;">
<a href="%s"
style="background:#2563eb;color:#fff;padding:14px 32px;text-decoration:none;border-radius:8px;font-size:16px;font-weight:bold;">
Activate Account
</a>
</div>
<p>If the button does not work, copy this link into your browser:</p>
<p style="word-break:break-all;color:#2563eb;">%s</p>
<p>This link expires in <strong>%d hours</strong>.</p>
</body>
</html>`, 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
}