Files

263 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package common
import (
"bytes"
"context"
"crypto/tls"
"html/template"
"net/mail"
"strings"
"time"
"github.com/qianfree/team-api/internal/dao"
"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
FromName string // 发件人显示名,收件人邮件列表里看到的名字;为空时只显示邮箱地址
UseTLS bool
}
// emailMaxAttempts 单封邮件的最大投递尝试次数(含首次),失败间隔 2s / 4s。
const emailMaxAttempts = 3
// 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 (3 attempts, 2s/4s backoff).
// 重试间隔可被 ctx 取消打断:调用方设了超时(如配置连通性测试)时不会被死等拖满。
func (s *BasicEmailSender) Send(ctx context.Context, msg *EmailMessage) error {
var lastErr error
for attempt := 0; attempt < emailMaxAttempts; attempt++ {
lastErr = s.sendWithRetry(ctx, msg, attempt)
if lastErr == nil {
// Log success
s.logSend(ctx, msg, "sent", nil, attempt)
return nil
}
if attempt < emailMaxAttempts-1 {
if err := sleepCtx(ctx, time.Duration(attempt+1)*2*time.Second); err != nil {
// ctx 已取消/超时:不再重试,按失败记录当前已知错误
s.logSend(ctx, msg, "failed", lastErr, attempt)
return lastErr
}
}
}
// Log failure
s.logSend(ctx, msg, "failed", lastErr, emailMaxAttempts-1)
return lastErr
}
// SendOnce 只尝试一次投递,不做重试,同样写入 ntf_send_log。
// 用于配置连通性测试等需要「快速失败并拿到真实 SMTP 错误」的场景——
// 认证失败、端口不通这类错误重试三次也不会变好,只会让管理员多等十几秒。
func (s *BasicEmailSender) SendOnce(ctx context.Context, msg *EmailMessage) error {
if err := s.sendWithRetry(ctx, msg, 0); err != nil {
s.logSend(ctx, msg, "failed", err, 0)
return err
}
s.logSend(ctx, msg, "sent", nil, 0)
return nil
}
// sleepCtx 等待 d 或直到 ctx 结束,ctx 先结束时返回其错误。
func sleepCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// 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 {
tpl, err := loadNotificationTemplate(ctx, templateCode)
if err != nil {
return err
}
// 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()
// 发件人显示名:SetAddressHeader 会按 RFC 5322 拼成 "aifree" <system@mail.aifree.com>,
// 名称含中文时自动做 RFC 2047 编码;FromName 为空时退化为纯地址。
// From 若已配成 "名称 <地址>" 形式,先拆出地址避免二次包装。
fromAddr, fromName := s.config.From, s.config.FromName
if parsed, err := mail.ParseAddress(fromAddr); err == nil {
fromAddr = parsed.Address
if fromName == "" {
fromName = parsed.Name
}
}
m.SetAddressHeader("From", fromAddr, fromName)
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"),
FromName: Config().GetString(ctx, "email_smtp_from_name"),
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.FromName == "" {
cfg.FromName = Config().GetString(ctx, "site_name")
}
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, ".")
}