邮件模板听起来像前端工作,但后端经常要负责生成验证码、邀请链接、账单通知和任务提醒。很多初学项目一开始直接用 fmt.Sprintf 拼 HTML,写到第二封邮件时就会变得难维护。Go 的 html/template 很适合渲染 HTML 邮件,因为它会自动转义用户数据。
本文用验证码邮件做例子,讲模板结构、数据模型、HTML 与纯文本内容、测试方式,以及哪些内容不应该直接信任。
定义邮件数据
先定义模板输入:
type VerifyEmailData struct {
ProductName string
UserName string
Code string
ExpiresIn string
}
不要把整个用户模型传给模板。邮件只需要几个字段,就定义几个字段。这样模板不会意外依赖数据库结构,也不容易把敏感字段带进去。
HTML 模板
示例模板:
const verifyHTML = `
<!doctype html>
<html lang="zh-CN">
<body>
<h1>{{.ProductName}} 验证码</h1>
<p>{{.UserName}},你好:</p>
<p>你的验证码是:</p>
<p style="font-size: 24px; font-weight: bold;">{{.Code}}</p>
<p>验证码将在 {{.ExpiresIn}} 后失效。</p>
</body>
</html>
`
渲染:
func RenderVerifyHTML(data VerifyEmailData) (string, error) {
tmpl, err := template.New("verify-html").Parse(verifyHTML)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
生产项目里不要每次请求都解析模板,可以在启动时解析好并复用。这里为了讲清楚流程,先写成独立函数。
纯文本兜底
很多邮件服务支持同时发送 HTML 和纯文本。纯文本对某些客户端、可访问性和调试都很有帮助:
const verifyText = `
{{.ProductName}} 验证码
{{.UserName}},你好:
你的验证码是:{{.Code}}
验证码将在 {{.ExpiresIn}} 后失效。
`
文本模板可以用 text/template:
func RenderVerifyText(data VerifyEmailData) (string, error) {
tmpl, err := texttemplate.New("verify-text").Parse(verifyText)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
HTML 用 html/template,纯文本用 text/template。不要把两者混用。输出到 HTML 的内容需要上下文转义,输出到文本则不需要 HTML 转义。
模板函数
邮件里常有时间格式化:
funcMap := template.FuncMap{
"formatTime": func(t time.Time) string {
return t.Format("2006-01-02 15:04")
},
}
注册:
tmpl := template.Must(template.New("notice").Funcs(funcMap).Parse(`
<p>任务将在 {{formatTime .RunAt}} 执行。</p>
`))
模板函数应该保持纯粹。不要在模板函数里查数据库、调用外部接口或生成验证码。验证码应该在业务层生成,再作为数据传给模板。模板只负责展示。
不要在模板里放密钥
邮件经常包含链接:
type InviteEmailData struct {
AcceptURL string
}
链接里的 token 应该由业务层生成,模板只展示:
<a href="{{.AcceptURL}}">接受邀请</a>
不要在模板里拼接签名逻辑,也不要把密钥作为模板数据传进去。模板文件可能被更多人查看,模板日志也可能被打印。密钥和签名属于业务层或安全组件,不属于模板层。
测试渲染结果
测试关键内容:
func TestRenderVerifyHTML(t *testing.T) {
html, err := RenderVerifyHTML(VerifyEmailData{
ProductName: "Plume",
UserName: "<script>alert(1)</script>",
Code: "123456",
ExpiresIn: "10 分钟",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "123456") {
t.Fatal("code missing")
}
if strings.Contains(html, "<script>") {
t.Fatal("username was not escaped")
}
}
这个测试能防止有人把 html/template 换成字符串拼接。邮件也是用户会看到的页面,只是显示在邮箱客户端里。安全边界一样重要。
启动时解析模板
更接近生产的写法:
type MailRenderer struct {
verifyHTML *template.Template
verifyText *texttemplate.Template
}
func NewMailRenderer() (*MailRenderer, error) {
htmlT, err := template.New("verify-html").Parse(verifyHTML)
if err != nil {
return nil, err
}
textT, err := texttemplate.New("verify-text").Parse(verifyText)
if err != nil {
return nil, err
}
return &MailRenderer{verifyHTML: htmlT, verifyText: textT}, nil
}
这样模板语法错误会在服务启动时暴露,而不是等用户触发邮件时才失败。邮件发送通常是关键流程,失败要尽量早发现。
渲染和发送分开
邮件渲染成功不代表邮件一定发送成功。真实服务里通常会把渲染和发送分成两步:
type Sender interface {
Send(ctx context.Context, msg Message) error
}
type Message struct {
To string
Subject string
HTML string
Text string
}
func SendVerifyEmail(ctx context.Context, sender Sender, data VerifyEmailData) error {
html, err := RenderVerifyHTML(data)
if err != nil {
return fmt.Errorf("render verify html: %w", err)
}
text, err := RenderVerifyText(data)
if err != nil {
return fmt.Errorf("render verify text: %w", err)
}
msg := Message{
To: data.UserName,
Subject: data.ProductName + " 验证码",
HTML: html,
Text: text,
}
if err := sender.Send(ctx, msg); err != nil {
return fmt.Errorf("send verify email: %w", err)
}
return nil
}
这样测试时可以替换 Sender,生产里再接真实邮件服务。不要在模板函数里直接发邮件,也不要在渲染函数里写数据库。把“生成内容”和“投递消息”分开,排查时更容易知道失败发生在哪一步。
还有一个实践细节:日志里不要打印完整验证码。可以打印用户 ID、邮件类型、发送结果,但验证码和重置链接属于敏感信息。调试方便不能压过安全边界。
小结
Go 渲染邮件模板可以用 html/template 处理 HTML 内容,用 text/template 处理纯文本内容。模板输入应使用专门的数据结构,只传页面需要的字段。验证码、签名、链接 token 等业务逻辑在模板外生成。
邮件模板的本质也是输出用户可见内容。自动转义、测试关键字段、启动时解析模板、避免泄漏敏感信息,这些习惯能让邮件功能更稳、更容易维护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。