发邮件:SendGrid 事务邮件

背景:为什么不直接用 SMTP

发邮件有两种姿势:

  1. 自建 SMTP(自己的 Postfix / 云厂商邮件服务):要自己管发信域名、SPF / DKIM / DMARC 三件套、退信处理、IP 信誉,否则进垃圾箱概率极高。
  2. 事务邮件服务(SendGrid / SES / Mailgun):把「送达率」问题外包出去,只关心「谁发给谁、内容是什么」。

本项目的发信层基于 SendGrid,核心优势有二:

  • Dynamic Templates(动态模板):邮件正文是 SendGrid 后台的一份 HTML 模板,由 template_id 引用;Go 代码只负责塞变量(codeto_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.StatusCodeSendGrid 成功是 202,不是常见的 200
  • 这里只 log 非 202、不返回 error:是否把「发送排队但未确认送达」当成业务失败,取决于你的容忍度。若希望严格,可以把非 202 也 return fmt.Errorf(...)

改邮件文案:不用发版 Go

模板正文在 SendGrid 后台维护,这是这层封装最大的好处:

  1. 登录 SendGridDynamic Templates → 找到对应的 template IDd-erwetnig87huwuy4tiwh...fwe)→ 改 HTML
  2. 改完不用发版 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 走配置,别进版本库。