希曼日记

买家回复激活码邮件被退信:一个 Reply-To 头,和顺便重写的邮件模板

约 6 分钟2,428 字

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 这个词的语义本来就是「别回我」——是我们一边用它发信、一边在正文里邀请回复,才凑出了这个死循环。

回复路径对比:没有 Reply-To 时回复落在 noreply 上被退信,有了 Reply-To 则落到支持邮箱

SPF / DKIM / DMARC 是另一件事:它们决定「我们的邮件会不会进垃圾箱」,影响的是投递率,不是这次退信的直接原因。这次不动它们。

修复:Reply-To,以及它该指向谁

Reply-To 必须是一个真实有人处理的邮箱。Scorpius 早就有一个:support@looke.cloud(订单页、支付失败页都在用)。问题只在前端没人把它接到邮件信头上。

改法分三层,从「配什么」到「写进信头」(代码是简化过的骨架,域名与地址保留原样):

Go
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() 里写,地址解析失败就不写这个头,而不是塞一行坏头进去把整封信搞砸:

Go
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")
}

地址从哪来?给一条明确的优先级,别让运维猜:

优先级来源说明
1MAIL_REPLY_TO(环境变量)显式指定,解析失败直接拒绝启动
2SUPPORT_EMAIL服务已有配置,默认 support@looke.cloud
3都没有不写 Reply-To,启动日志告警

默认值取 SUPPORT_EMAIL 而不是新增一个必填项,是刻意的:支持邮箱本来就存在,让默认行为就是对的,比要求「记得再配一个变量」可靠得多。

信头前后对比:修复前只有 From,修复后多出一行 Reply-To 指向支持邮箱

中文显示名:为什么必须 RFC 2047 编码

Reply-To: Scorpius 支持 <support@looke.cloud> 看着没问题,但「支持」这两个汉字是非 ASCII 字符。SMTP 的信头默认只允许 ASCII,非 ASCII 要么走 SMTPUTF8 扩展,要么按 RFC 2047 编码。

现实是:绝大多数中继不开 SMTPUTF8。原样写进去,轻则收件方显示乱码,重则被拒收。所以发送前统一编码:

Go
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

修退信的时候顺便看了下这封邮件本身——它一直是纯文本:

Text
你好,
感谢购买 Scorpius(买断 · 12 个月更新)。你的激活码:
Y6A3G-W5NPE-Y94KB-WMBXL
使用方法:
1. 从官网下载并安装:https://scorpius.apikv.com
macOS 首次打开请在 Finder 中右键 → 打开;…

能用,但一个 6 元的买断产品,付完钱收到一段 1990 年代邮件,观感上很掉价。改成品牌 HTML,同时守住两条约束:不能因此变得更难读、不能把发信链路改复杂。

结构:multipart/alternative

一封邮件同时带纯文本和 HTML 两个部分,客户端挑自己最富的那个渲染;只认文本的客户端拿到的就是原来的纯文本版:

Go
type Message struct {
Subject string
Text string // 纯文本部分,独立读完也要成立
HTML string // 可选;为空时退化成单部分 text/plain
}
Go
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 兜底:

HTML
<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>
CSS
@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 拉镜像),线上确认:

检查项结果
/version1.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 一行字,把这趟路接上了。