Files
likedream/internal/service/email/email_service.go
T
2026-03-26 15:19:43 +08:00

578 lines
17 KiB
Go

package email
import (
"bytes"
"context"
"embed"
"encoding/gob"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
cst2 "likedream-goframe/internal/common/cst"
db "likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/logger"
email2 "likedream-goframe/internal/framework/model/email"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/os/gctx"
"github.com/hibiken/asynq"
mail "github.com/wneessen/go-mail"
"go.uber.org/zap"
)
//go:embed templates/*.gohtml
var templateFiles embed.FS
const (
AuthModeSMTP = "smtp"
AuthModeOutlookOAuth2 = "outlook_oauth2"
DefaultOutlookSMTPHost = "smtp-mail.outlook.com"
DefaultOutlookSMTPPort = 587
DefaultOutlookTenant = "consumers"
DefaultOutlookOAuthScope = "offline_access https://graph.microsoft.com/Mail.Send"
DefaultOutlookRedirectURL = "http://localhost:3200/api/v1/mail/oauth/outlook/callback"
DefaultGraphSendMailURL = "https://graph.microsoft.com/v1.0/me/sendMail"
)
type EmailServiceV1 struct{}
type EmailConfig struct {
AuthMode string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
FromEmail string
FromName string
OAuthTenant string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURL string
OAuthRefreshToken string
OAuthScope string
}
func NewV1() *EmailServiceV1 {
return &EmailServiceV1{}
}
func (s *EmailServiceV1) GetConfig() EmailConfig {
ctx := gctx.New()
getVal := func(key string) string {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(key)).
Only(ctx)
if err == nil {
return config.Value
}
return ""
}
port, _ := strconv.Atoi(getVal(cst2.ConfigEmailSMTPPort))
cfg := EmailConfig{
AuthMode: getVal(cst2.ConfigEmailAuthMode),
SMTPHost: getVal(cst2.ConfigEmailSMTPHost),
SMTPPort: port,
SMTPUsername: getVal(cst2.ConfigEmailSMTPUsername),
SMTPPassword: getVal(cst2.ConfigEmailSMTPPassword),
FromEmail: getVal(cst2.ConfigEmailFromEmail),
FromName: getVal(cst2.ConfigEmailFromName),
OAuthTenant: getVal(cst2.ConfigEmailOAuthTenant),
OAuthClientID: getVal(cst2.ConfigEmailOAuthClientID),
OAuthClientSecret: getVal(cst2.ConfigEmailOAuthClientSecret),
OAuthRedirectURL: getVal(cst2.ConfigEmailOAuthRedirectURL),
OAuthRefreshToken: getVal(cst2.ConfigEmailOAuthRefreshToken),
OAuthScope: getVal(cst2.ConfigEmailOAuthScope),
}
return NormalizeConfig(cfg)
}
func NormalizeConfig(cfg EmailConfig) EmailConfig {
if cfg.AuthMode == "" {
cfg.AuthMode = AuthModeSMTP
}
if cfg.FromEmail != "" && cfg.SMTPUsername == "" {
cfg.SMTPUsername = cfg.FromEmail
}
if cfg.AuthMode == AuthModeOutlookOAuth2 {
cfg.OAuthTenant = DefaultOutlookTenant
cfg.OAuthScope = DefaultOutlookOAuthScope
cfg.OAuthRedirectURL = DefaultOutlookRedirectURL
} else if cfg.SMTPPort == 0 {
cfg.SMTPPort = 587
}
return cfg
}
func (s *EmailServiceV1) ValidateConfig(cfg EmailConfig) error {
cfg = NormalizeConfig(cfg)
if cfg.FromEmail == "" {
return fmt.Errorf("fromEmail is required")
}
if cfg.FromName == "" {
return fmt.Errorf("fromName is required")
}
switch cfg.AuthMode {
case AuthModeSMTP:
if cfg.SMTPHost == "" {
return fmt.Errorf("smtpHost is required")
}
if cfg.SMTPPort <= 0 {
return fmt.Errorf("smtpPort is required")
}
if cfg.SMTPUsername == "" {
return fmt.Errorf("smtpUsername is required")
}
if cfg.SMTPPassword == "" {
return fmt.Errorf("smtpPassword is required")
}
case AuthModeOutlookOAuth2:
if cfg.OAuthClientID == "" {
return fmt.Errorf("oauthClientId is required")
}
if cfg.OAuthClientSecret == "" {
return fmt.Errorf("oauthClientSecret is required")
}
if cfg.OAuthRefreshToken == "" {
return fmt.Errorf("oauthRefreshToken is required")
}
default:
return fmt.Errorf("unsupported authMode: %s", cfg.AuthMode)
}
return nil
}
func (s *EmailServiceV1) AccessToken(ctx context.Context, cfg EmailConfig) (string, error) {
cfg = NormalizeConfig(cfg)
if cfg.AuthMode != AuthModeOutlookOAuth2 {
return "", fmt.Errorf("access token is only available in oauth2 mode")
}
form := url.Values{
"client_id": {cfg.OAuthClientID},
"client_secret": {cfg.OAuthClientSecret},
"grant_type": {"refresh_token"},
"refresh_token": {cfg.OAuthRefreshToken},
"scope": {cfg.OAuthScope},
}
if cfg.OAuthRedirectURL != "" {
form.Set("redirect_uri", cfg.OAuthRedirectURL)
}
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", cfg.OAuthTenant)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
return "", fmt.Errorf("decode oauth token response failed: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if tokenResp.ErrorDescription != "" {
return "", fmt.Errorf("oauth token refresh failed: %s (%s)", tokenResp.ErrorDescription, tokenResp.Error)
}
return "", fmt.Errorf("oauth token refresh failed with status %d", resp.StatusCode)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("empty access token returned by oauth2 token endpoint")
}
return strings.TrimSpace(tokenResp.AccessToken), nil
}
func (s *EmailServiceV1) newClient(ctx context.Context, cfg EmailConfig) (*mail.Client, error) {
cfg = NormalizeConfig(cfg)
if err := s.ValidateConfig(cfg); err != nil {
return nil, err
}
if cfg.AuthMode != AuthModeSMTP {
return nil, fmt.Errorf("smtp client is only available in smtp mode")
}
options := []mail.Option{
mail.WithPort(cfg.SMTPPort),
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithTimeout(30 * time.Second),
mail.WithUsername(cfg.SMTPUsername),
}
options = append(options,
mail.WithSMTPAuth(mail.SMTPAuthLogin),
mail.WithPassword(cfg.SMTPPassword),
)
return mail.NewClient(cfg.SMTPHost, options...)
}
func (s *EmailServiceV1) newMessage(fromName, fromEmail, to, subject, body string) (*mail.Msg, error) {
msg := mail.NewMsg()
if err := msg.FromFormat(fromName, fromEmail); err != nil {
return nil, err
}
if err := msg.AddTo(to); err != nil {
return nil, err
}
msg.Subject(subject)
msg.SetBodyString(mail.TypeTextHTML, body)
return msg, nil
}
func (s *EmailServiceV1) SendMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
cfg = NormalizeConfig(cfg)
if err := s.ValidateConfig(cfg); err != nil {
return err
}
if cfg.AuthMode == AuthModeOutlookOAuth2 {
return s.sendGraphMessageNow(ctx, cfg, to, subject, body)
}
msg, err := s.newMessage(cfg.FromName, cfg.FromEmail, to, subject, body)
if err != nil {
return err
}
client, err := s.newClient(ctx, cfg)
if err != nil {
return err
}
defer client.Close()
return client.DialAndSendWithContext(ctx, msg)
}
func (s *EmailServiceV1) sendGraphMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
accessToken, err := s.AccessToken(ctx, cfg)
if err != nil {
return err
}
payload := map[string]any{
"message": map[string]any{
"subject": subject,
"body": map[string]any{
"contentType": "HTML",
"content": body,
},
"toRecipients": []map[string]any{
{
"emailAddress": map[string]any{
"address": to,
},
},
},
},
"saveToSentItems": true,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, DefaultGraphSendMailURL, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
var graphErr struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&graphErr)
if graphErr.Error.Message != "" {
return fmt.Errorf("graph sendMail failed: %s (%s)", graphErr.Error.Message, graphErr.Error.Code)
}
return fmt.Errorf("graph sendMail failed with status %d", resp.StatusCode)
}
return nil
}
func ConfigFromPayload(payload email2.SendPayload) EmailConfig {
return NormalizeConfig(EmailConfig{
AuthMode: payload.AuthMode,
SMTPHost: payload.SMTPHost,
SMTPPort: payload.SMTPPort,
SMTPUsername: payload.SMTPUsername,
SMTPPassword: payload.SMTPPassword,
FromEmail: payload.FromEmail,
FromName: payload.FromName,
OAuthTenant: payload.OAuthTenant,
OAuthClientID: payload.OAuthClientID,
OAuthClientSecret: payload.OAuthClientSecret,
OAuthRedirectURL: payload.OAuthRedirectURL,
OAuthRefreshToken: payload.OAuthRefreshToken,
OAuthScope: payload.OAuthScope,
})
}
func PayloadFromConfig(cfg EmailConfig, to, subject, body string) *email2.SendPayload {
cfg = NormalizeConfig(cfg)
return &email2.SendPayload{
To: to,
Subject: subject,
Body: body,
AuthMode: cfg.AuthMode,
FromName: cfg.FromName,
FromEmail: cfg.FromEmail,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPassword: cfg.SMTPPassword,
OAuthTenant: cfg.OAuthTenant,
OAuthClientID: cfg.OAuthClientID,
OAuthClientSecret: cfg.OAuthClientSecret,
OAuthRedirectURL: cfg.OAuthRedirectURL,
OAuthRefreshToken: cfg.OAuthRefreshToken,
OAuthScope: cfg.OAuthScope,
}
}
func templatePrefix() string {
if IsNewBrand() {
return "new_"
}
return ""
}
func brandAppName() string {
return GetBrandConfig().AppName
}
func brandCopyright() string {
return GetBrandConfig().AppCopyright
}
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("欢迎注册%s", brandAppName())
body, err := s.renderSignupEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("render signup email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send signup email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("注册邮件发送成功")
}
func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail, taskName string, status email2.TaskStatus, details map[string]string) *utils.Response[any] {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("任务状态通知: %s", taskName)
body, err := s.renderTaskNotificationTemplate(taskName, status, details)
if err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("通知邮件发送成功")
}
func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("重置%s账户密码", brandAppName())
body, err := s.renderResetPasswordEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("render reset password email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send reset password email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("重置密码邮件发送成功")
}
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("%s积分转移通知", brandAppName())
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
if err != nil {
logger.Logger.Error("render balance migrate email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send balance migrate email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("积分转移邮件发送成功")
}
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
return err
}
payload := bytes.NewBuffer(nil)
if err := gob.NewEncoder(payload).Encode(PayloadFromConfig(cfg, to, subject, body)); err != nil {
return err
}
task := queue.AddTask(
ctx,
asynq.NewTask(cst2.TaskEmail, payload.Bytes()),
asynq.Queue(cst2.TaskQueueEmail),
asynq.MaxRetry(1),
asynq.Timeout(5*time.Minute),
)
logger.Logger.Info("add email task", zap.String("task_id", task.ID), zap.String("to", to))
return nil
}
func parseTemplates(contentTemplate string) (*template.Template, error) {
prefix := templatePrefix()
layoutFile := fmt.Sprintf("templates/%slayout.gohtml", prefix)
contentFile := fmt.Sprintf("templates/%s%s", prefix, contentTemplate)
return template.ParseFS(templateFiles, layoutFile, contentFile)
}
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("signup_confirm.gohtml")
if err != nil {
return "", fmt.Errorf("解析注册邮件模板失败: %v", err)
}
bc := GetBrandConfig()
data := NewSignupTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailConfirmBaseUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
if err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data); err != nil {
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
}
return buf.String(), nil
}
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status email2.TaskStatus, details map[string]string) (string, error) {
tmpl, err := parseTemplates("task_notification.gohtml")
if err != nil {
return "", fmt.Errorf("解析任务通知邮件模板失败: %v", err)
}
bc := GetBrandConfig()
data := NewTaskNotificationTemplateData(taskName, status, details, bc.AppName, bc.AppCopyright)
prefix := templatePrefix()
var buf bytes.Buffer
if err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data); err != nil {
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
}
return buf.String(), nil
}
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("reset_password.gohtml")
if err != nil {
return "", fmt.Errorf("解析重置密码邮件模板失败: %v", err)
}
bc := GetBrandConfig()
data := NewResetPasswordTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailResetPasswordUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
if err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data); err != nil {
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
}
return buf.String(), nil
}
func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, balance int, token string) (string, error) {
tmpl, err := parseTemplates("balance_migrate.gohtml")
if err != nil {
return "", fmt.Errorf("解析积分转移邮件模板失败: %v", err)
}
bc := GetBrandConfig()
data := NewBalanceMigrateTemplateData(username, bc.AppName, bc.AppCopyright, balance, bc.BalanceMigrateUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
if err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data); err != nil {
return "", fmt.Errorf("渲染积分转移邮件模板失败: %v", err)
}
return buf.String(), nil
}