Files
mengyastore/mengyastore-backend-go/internal/email/email.go

194 lines
6.1 KiB
Go
Raw 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 email
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
"time"
)
// Config 存储 SMTP 发件配置。
type Config struct {
SMTPHost string // 例smtp.qq.com
SMTPPort string // 例465SSL或 587STARTTLS
From string // 发件人邮箱地址
Password string // SMTP 密码或授权码
FromName string // 显示名称,例:"萌芽小店"
}
// IsConfigured 判断配置是否充足,可以尝试发送邮件。
func (c *Config) IsConfigured() bool {
return c.From != "" && c.Password != "" && c.SMTPHost != ""
}
// OrderNotifyData 包含发送订单通知邮件所需的数据。
type OrderNotifyData struct {
ToEmail string
ToName string
ProductName string
OrderID string
Quantity int
Codes []string // 手动发货时为空
IsManual bool
}
// SendOrderNotify 发送订单发货通知邮件。
// 若配置不完整或收件人为空,则静默跳过,返回 nil。
func SendOrderNotify(cfg Config, data OrderNotifyData) error {
if !cfg.IsConfigured() || data.ToEmail == "" {
return nil
}
if cfg.SMTPPort == "" {
cfg.SMTPPort = "465"
}
if cfg.SMTPHost == "" {
cfg.SMTPHost = "smtp.qq.com"
}
fromName := cfg.FromName
if fromName == "" {
fromName = "萌芽小店"
}
subject := "【萌芽小店】您的订单已发货"
if data.IsManual {
subject = "【萌芽小店】您的订单正在处理中"
}
body := buildBody(data)
msg := buildMIMEMessage(cfg.From, fromName, data.ToEmail, subject, body)
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
auth := smtp.PlainAuth("", cfg.From, cfg.Password, cfg.SMTPHost)
// QQ 邮箱使用 SSL端口 465需直接 TLS 拨号。
if cfg.SMTPPort == "465" {
return sendSSL(addr, cfg.SMTPHost, auth, cfg.From, data.ToEmail, msg)
}
return smtp.SendMail(addr, auth, cfg.From, []string{data.ToEmail}, []byte(msg))
}
func buildBody(data OrderNotifyData) string {
var sb strings.Builder
now := time.Now().Format("2006 年 01 月 02 日 15:04:05")
recipient := data.ToName
if recipient == "" {
recipient = "用户"
}
sb.WriteString("尊敬的 ")
sb.WriteString(recipient)
sb.WriteString("\n\n")
sb.WriteString(" 您好!感谢您在萌芽小店的支持与购买。\n\n")
sb.WriteString("────────────────────────────────\n")
sb.WriteString(" 订单信息\n")
sb.WriteString("────────────────────────────────\n")
sb.WriteString(fmt.Sprintf(" 商品名称:%s\n", data.ProductName))
sb.WriteString(fmt.Sprintf(" 订单编号:%s\n", data.OrderID))
sb.WriteString(fmt.Sprintf(" 购买数量:%d 件\n", data.Quantity))
sb.WriteString(fmt.Sprintf(" 通知时间:%s\n", now))
sb.WriteString("────────────────────────────────\n\n")
if data.IsManual {
sb.WriteString(" 您的订单已成功提交,目前正在等待人工审核与处理。\n")
sb.WriteString(" 工作人员将尽快为您安排发货,请耐心等候。\n")
sb.WriteString(" 发货完成后,我们将另行发送邮件通知。\n\n")
} else {
sb.WriteString(" 您的订单已完成自动发货,发货内容如下:\n\n")
if len(data.Codes) > 0 {
for i, code := range data.Codes {
sb.WriteString(fmt.Sprintf(" [%d] %s\n", i+1, code))
}
sb.WriteString("\n")
}
sb.WriteString(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n")
}
sb.WriteString(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n")
sb.WriteString("────────────────────────────────\n")
sb.WriteString(" 此邮件由系统自动发送,请勿直接回复。\n")
sb.WriteString("────────────────────────────────\n")
return sb.String()
}
// buildMIMEMessage 构建符合 MIME 规范的邮件报文,支持 UTF-8 中文。
func buildMIMEMessage(from, fromName, to, subject, body string) string {
encodedFromName := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(fromName))
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(subject))
return fmt.Sprintf(
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n%s",
encodedFromName, from, to, encodedSubject, encodeBase64(body),
)
}
func encodeBase64(s string) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
b := []byte(s)
var buf strings.Builder
for i := 0; i < len(b); i += 3 {
remaining := len(b) - i
b0 := b[i]
b1 := byte(0)
b2 := byte(0)
if remaining > 1 {
b1 = b[i+1]
}
if remaining > 2 {
b2 = b[i+2]
}
buf.WriteByte(chars[b0>>2])
buf.WriteByte(chars[((b0&0x03)<<4)|(b1>>4)])
if remaining > 1 {
buf.WriteByte(chars[((b1&0x0f)<<2)|(b2>>6)])
} else {
buf.WriteByte('=')
}
if remaining > 2 {
buf.WriteByte(chars[b2&0x3f])
} else {
buf.WriteByte('=')
}
}
return buf.String()
}
// sendSSL 通过 TLS 直接拨号发送邮件(适用于 465 端口 SSL 连接,如 QQ 邮箱)。
func sendSSL(addr, host string, auth smtp.Auth, from, to string, msg string) error {
tlsConfig := &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("tls 拨号失败: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
}
defer client.Quit() //nolint:errcheck
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP 认证失败: %w", err)
}
if err = client.Mail(from); err != nil {
return fmt.Errorf("SMTP MAIL FROM 失败: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO 失败: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA 失败: %w", err)
}
if _, err = fmt.Fprint(w, msg); err != nil {
return fmt.Errorf("写入邮件内容失败: %w", err)
}
return w.Close()
}