Go 邮件模板入门:把验证码和通知邮件渲染清楚

用验证码邮件示例讲 Go html/template 渲染邮件内容、纯文本兜底、模板数据结构、测试和安全边界。

邮件模板听起来像前端工作,但后端经常要负责生成验证码、邀请链接、账单通知和任务提醒。很多初学项目一开始直接用 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 等业务逻辑在模板外生成。

邮件模板的本质也是输出用户可见内容。自动转义、测试关键字段、启动时解析模板、避免泄漏敏感信息,这些习惯能让邮件功能更稳、更容易维护。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页