发邮件:SendGrid 事务邮件
背景:为什么不直接用 SMTP
发邮件有两种姿势:
- 自建 SMTP(自己的 Postfix / 云厂商邮件服务):要自己管发信域名、SPF / DKIM / DMARC 三件套、退信处理、IP 信誉,否则进垃圾箱概率极高。
- 事务邮件服务(SendGrid / SES / Mailgun):把「送达率」问题外包出去,只关心「谁发给谁、内容是什么」。
本项目的发信层基于 SendGrid,核心优势有二:
- Dynamic Templates(动态模板):邮件正文是 SendGrid 后台的一份 HTML 模板,由
template_id引用;Go 代码只负责塞变量(code、to_email)。 - 改文案不动代码:HTML 在 SendGrid 后台改完,下一封邮件就会用新文案,不需要重新编译或发布 Go 服务。
代码结构
整个 email 包就是「构造 mail.Email 收发件人 + 用模板渲染正文 + 走 SendGrid 客户端发出去」这一条链路的封装。
package email
import (
"github.com/phuslu/log"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
"space.nation/config"
)
// FromNoReply 系统自动邮件的统一发件人(验证码、通知等)
func FromNoReply() *mail.Email {
return mail.NewEmail("XXX", "noreply@xx.xx")
}
// FromSupport 用户回信时的 Reply-To 地址,方便收件人直接回复到人工客服
func FromSupport() *mail.Email {
return mail.NewEmail("XXX", "support@xxx.com")
}
// ToEmail 把字符串地址解析为 *mail.Email,顺便做格式校验
func ToEmail(email string) (*mail.Email, error) {
return mail.ParseEmail(email)
}
// ToUser 同 ToEmail,但额外带上收件人显示名(如 "Alice")
func ToUser(name string, email string) (*mail.Email, error) {
m, err := mail.ParseEmail(email)
if err != nil {
return nil, err
}
m.Name = name
return m, nil
}
发件人固定成两个函数,避免到处硬编码邮箱字符串;收件人用 ParseEmail 解析,格式非法直接报错,把「脏邮箱」挡在发送之前。
发送验证码(模板邮件)
// SendEmailVerifyCode 发送注册/登录验证码。
// 正文由 SendGrid 后台的 Dynamic Template(d-df345w...)渲染,
// 这里只注入两个变量:收件人地址和验证码。
func SendEmailVerifyCode(to *mail.Email, code string) error {
m := mail.NewV3Mail()
m.SetFrom(FromNoReply())
m.SetReplyTo(FromSupport()) // 用户点回复会回到人工客服
p := mail.NewPersonalization()
p.AddTos(to)
p.SetDynamicTemplateData("to_email", to.Address)
p.SetDynamicTemplateData("code", code)
m.AddPersonalizations(p)
m.SetTemplateID("d-qwerpskdfg94jgxxxxx..xx")
return SendMessage(m)
}
要点:
NewV3Mail+SetTemplateID走的是 Transactional Template(v3),正文完全由后台模板决定,Go 侧不拼 HTML。SetDynamicTemplateData(key, value)注入模板里的{{to_email}}、{{code}}占位符。SetReplyTo(FromSupport()):用户回信进人工客服,而不是noreply黑洞。
统一出口:SendMessage
// SendMessage 所有邮件的统一发送入口。
func SendMessage(message *mail.SGMailV3) error {
client := sendgrid.NewSendClient(config.SendGridKey())
response, err := client.Send(message)
if err != nil {
return err
}
// 202 Accepted 表示 SendGrid 已接收并进入发送队列,是成功态。
// 非 202(如 401 密钥错、429 限流、4xx 模板/发件人问题)记录日志以便排查。
if response.StatusCode != 202 {
log.Info().Msgf("SendGrid status code: %d", response.StatusCode)
}
return nil
}
config.SendGridKey()从配置/环境变量读取 API Key,不要硬编码进仓库。client.Send返回的err是「网络/SDK 层」错误;HTTP 状态码走response.StatusCode。SendGrid 成功是202,不是常见的200。- 这里只
log非 202、不返回 error:是否把「发送排队但未确认送达」当成业务失败,取决于你的容忍度。若希望严格,可以把非 202 也return fmt.Errorf(...)。
改邮件文案:不用发版 Go
模板正文在 SendGrid 后台维护,这是这层封装最大的好处:
- 登录 SendGrid → Dynamic Templates → 找到对应的
template ID(d-erwetnig87huwuy4tiwh...fwe)→ 改 HTML。 - 改完不用发版 Go 服务,下一封触发
SendEmailVerifyCode的邮件就会用上新文案。
注意:Go 代码里的
template ID要和实际模板对应;如果从后台新建了一份模板,记得把新的d-xxxx同步到SetTemplateID这里——这一步才需要改代码。
扩展:再加一种邮件
照着 SendEmailVerifyCode 抄一个函数即可,差异只有「用哪个模板 + 注入哪些变量」:
func SendEmailWelcome(to *mail.Email) error {
m := mail.NewV3Mail()
m.SetFrom(FromNoReply())
m.SetReplyTo(FromSupport())
p := mail.NewPersonalization()
p.AddTos(to)
p.SetDynamicTemplateData("to_email", to.Address)
m.AddPersonalizations(p)
m.SetTemplateID("d-welcome-template-id") // 换成欢迎邮件的模板 ID
return SendMessage(m)
}
小结
email包把「发件人 / 收件人 / 模板变量 / 客户端」四件事拆开,调用方只写业务语义(SendEmailVerifyCode)。- 正文与代码解耦:文案在 SendGrid 后台改,改 HTML 不动 Go、不发版。
202是发送成功态;API Key 走配置,别进版本库。