feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
308
internal/service/email.go
Normal file
308
internal/service/email.go
Normal file
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user