买家回复激活码邮件被退信:一个 Reply-To 头,和顺便重写的邮件模板
Scorpius 是一个剪贴板管理器,官网只卖一样东西:6 元买断的激活码,含 12 个月更新。付完钱,系统把激活码发到买家留下的邮箱。
有一天收到一封退信——买家回复我们的激活码邮件,被退回来了。顺着查下去,发现这不是「邮件服务商的问题」,而是我们自己埋的:正文写着「有问题回复本邮件」,发件人却是一个只发不收的地址。
这篇按真实排查过程整理:为什么会退信、Reply-To 该怎么设、中文显示名为什么必须编码、文案要怎么改才不算忽悠人,以及顺手把纯文本邮件换成「客户端剥掉样式也读得下去」的品牌 HTML。
一封退信
买家回复后的几分钟,他收到了自己的退信:
很抱歉您发送的邮件被退回……无法发送到
noreply@apikv.com。退信原因:收件人(noreply@apikv.com)所属域名不存在,邮件无法送达。No Mx Record Found.
注意这句「所属域名不存在」。apikv.com 当然是存在的——我们每天从它上面收款。不存在的不是域名,是这个地址收信的能力:noreply@ 这类地址在 DNS 里通常没有 MX 记录,它天生就不该收信。
为什么会退信:From 不是 Reply-To
邮件头里有三个容易混的地址,各管一件事:
| 信头 | 谁决定 | 用在哪 |
|---|---|---|
From | 发件人 | 收件箱列表里显示「这是谁发的」 |
Reply-To | 发件人 | 用户点「回复」时,回复邮件送到哪 |
Return-Path | 收信方 MTA | 退信、DSN 报告送到哪 |
规则很直白:用户点「回复」时,邮件客户端优先取 Reply-To;没设就取 From。我们的激活码邮件没设 Reply-To,于是回复被寄到 noreply@apikv.com——一个没有 MX 的地址,收信方 MTA 查不到 MX 记录,只能退信。
顺带说清一个常见误解:MX 记录只对「收件方」生效。noreply@apikv.com 当发件人完全合法(SMTP 会话只认证发信账号),但它当收件人就必须有 MX。noreply 这个词的语义本来就是「别回我」——是我们一边用它发信、一边在正文里邀请回复,才凑出了这个死循环。
SPF / DKIM / DMARC 是另一件事:它们决定「我们的邮件会不会进垃圾箱」,影响的是投递率,不是这次退信的直接原因。这次不动它们。
修复:Reply-To,以及它该指向谁
Reply-To 必须是一个真实有人处理的邮箱。Scorpius 早就有一个:support@looke.cloud(订单页、支付失败页都在用)。问题只在前端没人把它接到邮件信头上。
改法分三层,从「配什么」到「写进信头」(代码是简化过的骨架,域名与地址保留原样):
type SMTP struct { // From 是只发不收的地址(noreply@apikv.com),回复绝不能落到它上面 From string // ReplyTo 是买家点「回复」时真正送达的邮箱,必须是能收信的地址 ReplyTo string}
// SetReplyTo 校验并设置回信地址;空串表示清除func (s *SMTP) SetReplyTo(addr string) error { if addr == "" { s.ReplyTo = "" return nil } if _, err := mail.ParseAddress(addr); err != nil { return fmt.Errorf("reply-to: %w", err) } s.ReplyTo = addr return nil}信头在 build() 里写,地址解析失败就不写这个头,而不是塞一行坏头进去把整封信搞砸:
b.WriteString("From: " + headerAddress(s.From) + "\r\n")b.WriteString("To: " + to + "\r\n")// 没有这一行,「回复本邮件」就会寄往只发不收的 From 域名if _, err := mail.ParseAddress(s.ReplyTo); err == nil { b.WriteString("Reply-To: " + headerAddress(s.ReplyTo) + "\r\n")}地址从哪来?给一条明确的优先级,别让运维猜:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | MAIL_REPLY_TO(环境变量) | 显式指定,解析失败直接拒绝启动 |
| 2 | SUPPORT_EMAIL | 服务已有配置,默认 support@looke.cloud |
| 3 | 都没有 | 不写 Reply-To,启动日志告警 |
默认值取 SUPPORT_EMAIL 而不是新增一个必填项,是刻意的:支持邮箱本来就存在,让默认行为就是对的,比要求「记得再配一个变量」可靠得多。

中文显示名:为什么必须 RFC 2047 编码
Reply-To: Scorpius 支持 <support@looke.cloud> 看着没问题,但「支持」这两个汉字是非 ASCII 字符。SMTP 的信头默认只允许 ASCII,非 ASCII 要么走 SMTPUTF8 扩展,要么按 RFC 2047 编码。
现实是:绝大多数中继不开 SMTPUTF8。原样写进去,轻则收件方显示乱码,重则被拒收。所以发送前统一编码:
func headerAddress(addr string) string { p, err := mail.ParseAddress(addr) if err != nil { return addr } if p.Name == "" { return p.Address // 无显示名,直接裸地址 } if isASCII(p.Name) { return p.Name + " <" + p.Address + ">" } return mime.QEncoding.Encode("utf-8", p.Name) + " <" + p.Address + ">"}效果对比:
| 写法 | 实际信头 | 结果 |
|---|---|---|
| 原始中文显示名 | Reply-To: Scorpius 支持 <support@…> | 依赖 SMTPUTF8,多数中继会乱码或拒收 |
| RFC 2047 编码 | Reply-To: =?utf-8?q?Scorpius_=E6=94=AF=E6=8C=81?= <support@…> | 纯 ASCII,任何客户端都能正确显示「Scorpius 支持」 |
测试里直接断言「整封信的头必须全 ASCII」——这种事靠肉眼 review 迟早漏,交给断言。
文案:别让用户做一个无效动作
信头修好了,但文案也得改。原来的写法是:
有问题回复本邮件,或联系
support@looke.cloud。
这句话在信头修好之前,前半句是无效动作:用户照做,得到一封退信。改成:
有问题直接回复本邮件就行,我们收得到;也可以写信到
support@looke.cloud。
差别不只是加了「我们收得到」。更重要的是,这句话现在是真的——Reply-To 已经指向有人处理的邮箱,文案才敢这么写。给用户承诺之前,先让承诺成立;顺手保留支持邮箱地址,即使将来再出问题,用户手里也还有第二条路。
顺手:激活码邮件从纯文本到品牌 HTML
修退信的时候顺便看了下这封邮件本身——它一直是纯文本:
你好,
感谢购买 Scorpius(买断 · 12 个月更新)。你的激活码:
Y6A3G-W5NPE-Y94KB-WMBXL
使用方法:1. 从官网下载并安装:https://scorpius.apikv.com macOS 首次打开请在 Finder 中右键 → 打开;…能用,但一个 6 元的买断产品,付完钱收到一段 1990 年代邮件,观感上很掉价。改成品牌 HTML,同时守住两条约束:不能因此变得更难读、不能把发信链路改复杂。
结构:multipart/alternative
一封邮件同时带纯文本和 HTML 两个部分,客户端挑自己最富的那个渲染;只认文本的客户端拿到的就是原来的纯文本版:
type Message struct { Subject string Text string // 纯文本部分,独立读完也要成立 HTML string // 可选;为空时退化成单部分 text/plain}boundary := "scorpius-" + randomToken()fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary)b.WriteString("--" + boundary + "\r\n")b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")b.WriteString(normalizeCRLF(m.Text) + "\r\n")b.WriteString("--" + boundary + "\r\n")b.WriteString("Content-Type: text/html; charset=utf-8\r\n")b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")HTML 部分用 quoted-printable 而不是 8bit:模板里长样式行很容易超过 SMTP 的 998 字符上限,软换行后任何长度都安全。
HTML 的写法:越「土」越稳
邮件客户端的 CSS 支持度停留在十年前,所以约束比技巧重要:
| 约束 | 做法 |
|---|---|
| 不用 flex / grid | 全部 <table> 嵌套,宽度靠 width 属性和单元格 |
样式不放进 <style> | 关键样式全部内联,<style> 只放媒体查询 |
颜色不用 rgba() | 统一 hex;Outlook 的 Word 引擎不认 |
| 图标不用 SVG / 图标字体 | 用文字徽标(S 标、✓、数字序号圆点),最坏情况也是个字符 |
| 底色必须兜底 | 同时写 bgcolor 属性和 style="background:…",属性才是「无样式」时的兜底 |
这样做的最坏情况——客户端把所有样式都剥掉——布局还在:表格骨架、bgcolor 底色、align 对齐都还在,激活码、步骤、说明照顺序排下来,读得下去。


窄屏也不能破。容器用 width:100%; max-width:600px 加一段 Outlook 专属的条件注释;激活码是等宽加字距的长字符串,是撑宽表格的头号嫌疑人,除了媒体查询降字号,还给它上了 word-break:break-all 兜底:
<td class="scp-code" style="font-family:'SF Mono',Consolas,monospace; font-size:26px;letter-spacing:3px;word-break:break-all;">Y6A3G-W5NPE-Y94KB-WMBXL</td>@media (max-width: 480px) { .scp-code { font-size: 17px !important; letter-spacing: 2px !important; } .scp-pad { padding: 26px 18px !important; }}实测用 CDP 模拟 390px 视口:scrollWidth == clientWidth == 390,零横向溢出,验证码自动缩到 17px。
顺手:验证码邮件(Casdoor)
注册时发的验证码邮件是 Casdoor 发的,模板在 Casdoor 后台。原模板除了「冷冰冰」,还有个实际 bug——标题用了 %{user.friendlyName},这个变量为空时渲染出来是:
Hi, Hi, 请验证你的身份
用户名占位符空值时不能就这么裸奔。新版直接去掉这个变量,标题写「你的登录验证码」,品牌头、珊瑚色虚线码卡、安全提示与订单邮件同一套语言:

这封的发件人和 Reply-To 在 Casdoor 的邮件连接器里,本项目只提供模板——如果那边也用的 noreply@,同一个坑还会再踩一次。
顺手:官网导航加登录入口
既然动了账号相关的东西,把官网导航的账号入口也补上:未登录显示「登录 / 注册」,已登录显示头像加「我的订单」,退出登录留在订单页,不在导航重复。

上线与验证
改动随镜像发布到集群(Argo CD 管理,TCR 拉镜像),线上确认:
| 检查项 | 结果 |
|---|---|
/version | 1.0.0-e8b85fa |
| API 启动日志 | code delivery by mail enabled … replyTo: support@looke.cloud |
| 环境变量 | MAIL_REPLY_TO 未设置 → 按设计回退 SUPPORT_EMAIL |
| 官网 / 镜像 | 产物含新导航与新版邮件 |
启动日志里那行 replyTo 是这次修复的「验尸点」:回信地址是多少、有没有生效,一眼可查。
还没做端到端验证的是真发一封再回复一次。最真实的路径是在「我的订单」里对某笔订单点「重新发送激活码」,收到后直接回复——如果还是退信,把退信原文拿来对比 Reply-To 那一行。
小结:发「带回复邀请」的邮件前的检查清单
| 检查 | 为什么 |
|---|---|
正文邀请回复前,先确认 Reply-To 存在且指向有人处理的邮箱 | 否则用户每一次回复都在浪费时间 |
| 回信地址给默认值,不要依赖「记得再配一个变量」 | 默认 SUPPORT_EMAIL,覆盖才显式指定 |
| 信头只写 ASCII,显示名按 RFC 2047 编码 | 多数中继不开 SMTPUTF8 |
| 纯文本部分独立可读 | 它同时是「不支持 HTML」和「样式全被剥掉」两条退路 |
表格布局 + 内联样式 + bgcolor 兜底 | 剥掉样式的客户端仍然读得下去 |
容器 max-width + 长字符串 word-break | 窄屏不横向溢出 |
| 上线后看一行启动日志 | 配置对不对,日志比配置中心更诚实 |
一封 6 元订单的邮件,退信损失的不止是一封回复——是买家在遇到问题时唯一愿意先尝试的那条路。Reply-To 一行字,把这趟路接上了。