309 lines
9.2 KiB
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
|
||
|
|
}
|