194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
package email
|
||
|
||
import (
|
||
"crypto/tls"
|
||
"fmt"
|
||
"net/smtp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Config 存储 SMTP 发件配置。
|
||
type Config struct {
|
||
SMTPHost string // 例:smtp.qq.com
|
||
SMTPPort string // 例:465(SSL)或 587(STARTTLS)
|
||
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()
|
||
}
|