Files
team-api/internal/logic/common/email.go
T
2026-05-10 12:21:27 +08:00

226 lines
5.3 KiB
Go

package common
import (
"bytes"
"context"
"crypto/tls"
"github.com/qianfree/team-api/internal/dao"
"html/template"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
do "github.com/qianfree/team-api/internal/model/do"
"gopkg.in/gomail.v2"
)
// EmailConfig holds SMTP configuration.
type EmailConfig struct {
Host string
Port int
Username string
Password string
From string
UseTLS bool
}
// BasicEmailSender provides email sending capability with:
// - SMTP connection
// - Template rendering (Go templates)
// - Retry (3 times)
// - Send log to ntf_send_log
type BasicEmailSender struct {
config *EmailConfig
}
// NewEmailSender creates a new BasicEmailSender.
func NewEmailSender(config *EmailConfig) *BasicEmailSender {
return &BasicEmailSender{config: config}
}
// EmailMessage represents an email to send.
type EmailMessage struct {
To string
Subject string
BodyHTML string
BodyText string
TemplateCode string
Variables map[string]any
TenantID int64
UserID int64
}
// Send sends an email message with retry logic.
func (s *BasicEmailSender) Send(ctx context.Context, msg *EmailMessage) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
lastErr = s.sendWithRetry(ctx, msg, attempt)
if lastErr == nil {
// Log success
s.logSend(ctx, msg, "sent", nil, attempt)
return nil
}
if attempt < 2 {
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
}
}
// Log failure
s.logSend(ctx, msg, "failed", lastErr, 2)
return lastErr
}
// SendTemplate sends an email using a template from ntf_templates.
func (s *BasicEmailSender) SendTemplate(ctx context.Context, to, templateCode string, variables map[string]any) error {
// Load template
var tpl struct {
Subject string `json:"subject"`
BodyTemplate string `json:"body_template"`
Channel string `json:"channel"`
}
err := dao.NtfTemplates.Ctx(ctx).
Where("code", templateCode).
Where("status", "active").
Scan(&tpl)
if err != nil {
return gerror.Wrapf(err, "load template %s", templateCode)
}
if tpl.BodyTemplate == "" {
return gerror.Newf("template %s not found", templateCode)
}
// Render subject
subject, err := renderTemplate(tpl.Subject, variables)
if err != nil {
return gerror.Wrapf(err, "render subject")
}
// Render body
body, err := renderTemplate(tpl.BodyTemplate, variables)
if err != nil {
return gerror.Wrapf(err, "render body")
}
msg := &EmailMessage{
To: to,
Subject: subject,
BodyHTML: body,
TemplateCode: templateCode,
Variables: variables,
}
return s.Send(ctx, msg)
}
// sendWithRetry performs the actual SMTP send.
func (s *BasicEmailSender) sendWithRetry(ctx context.Context, msg *EmailMessage, attempt int) error {
m := gomail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", msg.To)
m.SetHeader("Subject", msg.Subject)
if msg.BodyHTML != "" {
m.SetBody("text/html", msg.BodyHTML)
if msg.BodyText != "" {
m.AddAlternative("text/plain", msg.BodyText)
}
} else {
m.SetBody("text/plain", msg.BodyText)
}
dialer := gomail.NewDialer(
s.config.Host,
s.config.Port,
s.config.Username,
s.config.Password,
)
if s.config.UseTLS {
dialer.TLSConfig = &tls.Config{ServerName: s.config.Host}
}
return dialer.DialAndSend(m)
}
// logSend records the send attempt in ntf_send_log.
func (s *BasicEmailSender) logSend(ctx context.Context, msg *EmailMessage, status string, sendErr error, retryCount int) {
data := do.NtfSendLog{
TemplateCode: msg.TemplateCode,
Channel: "email",
Recipient: msg.To,
Subject: msg.Subject,
Status: status,
RetryCount: retryCount,
}
if sendErr != nil {
data.ErrorMessage = sendErr.Error()
}
if status == "sent" {
sentAt := gtime.NewFromTime(time.Now())
data.SentAt = sentAt
data.Body = msg.BodyHTML
}
if msg.TenantID > 0 {
data.TenantId = msg.TenantID
}
if msg.UserID > 0 {
data.UserId = msg.UserID
}
_, err := dao.NtfSendLog.Ctx(ctx).Data(data).Insert()
if err != nil {
g.Log().Errorf(ctx, "log email send: %v", err)
}
}
// renderTemplate renders a Go template string with the given variables.
func renderTemplate(tplStr string, vars map[string]any) (string, error) {
t, err := template.New("").Parse(tplStr)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, vars); err != nil {
return "", err
}
return buf.String(), nil
}
// EmailConfigFromOptions loads email config from the settings registry.
func EmailConfigFromOptions(ctx context.Context) (*EmailConfig, error) {
cfg := &EmailConfig{
Host: Config().GetString(ctx, "email_smtp_host"),
Port: Config().GetInt(ctx, "email_smtp_port"),
Username: Config().GetString(ctx, "email_smtp_username"),
Password: Config().GetString(ctx, "email_smtp_password"),
From: Config().GetString(ctx, "email_smtp_from"),
UseTLS: Config().GetBool(ctx, "email_smtp_tls"),
}
if cfg.Host == "" || cfg.From == "" {
return nil, gerror.New("email SMTP config not set (email_smtp_host, email_smtp_from)")
}
if cfg.Port == 0 {
cfg.Port = 587
}
return cfg, nil
}
// IsValidEmail checks if a string is a valid email format.
func IsValidEmail(email string) bool {
return strings.Contains(email, "@") && strings.Contains(email, ".")
}