Backend

给论坛做一套轻量的通知邮件模板,含一键退订

2182 字约 8 分钟

摘要通知邮件要好看、要小、还要能一键退订。记录了表格加内联样式的写法、每封不超过 4KB 的体积预算、退订链接为什么打开后还要再点一下,以及和其他论坛的邮件对比之后怎样充实内容。

论坛的发信渠道换成邮件推送服务之后,轮到邮件本身了。当时的通知邮件几乎是纯文字,我提了三个要求。排队发出的邮件之间随机隔一两秒,别一下子发出去一大批。每类邮件都用一套好看的 HTML 模板,但不能大。底部要放一个关闭提醒的链接,像大厂的邮件那样,是一行不起眼的小字。

这篇记录这套模板是怎么做的,以及中间几个容易想当然的地方。

邮件里能用的 HTML 很少

网页上的写法在邮件里大多行不通。邮件客户端对样式的支持参差不齐,很多都不认 <style> 标签,所以模板里只能用表格排版,所有样式都写成内联。

我给模板定了几条规矩。

  • 所有邮件共用一个外框,浅米色的底,中间一张最宽 560px 的纸色卡片,配色取自网站。
  • 不放图片。QQ 邮箱对不认识的发件人默认不显示图片,不放还能省流量。
  • 每封邮件的 HTML 不超过 4KB,每周来信这种内容较多的除外。同时附带纯文本版本。
  • 正文开头放一段隐藏的预览文字,收件箱列表里标题后面显示的就是它。

做出来的体积是,验证码邮件约 2KB,回复、升级、抽奖这类提醒在 2.2KB 到 3KB 之间,每周来信约 5.5KB。

底部小字是邮件里的另一个固定部分。第一行说明为什么会收到这封邮件,比如“你收到这封邮件,是因为在鼠洞开启了「回复我的楼层」的邮件提醒”,第二行是“不再接收此类邮件”和“管理邮件提醒”两个链接。验证码邮件没有退订链接,页脚只写一句“如果不是你本人操作,请忽略这封邮件”。

退订链接不能让别人伪造

退订必须在没登录的情况下完成,毕竟用户是在邮箱里点的。可是一个不需要登录的链接,很容易被人拿去关别人的提醒,只要把链接里的用户编号改一下就行了。

所以链接里带了一段签名。签名是服务端用一把只有自己知道的密钥,对“用户编号加邮件类型”做 HMAC 算出来的。链接长这样,用户编号、类型和签名三个参数缺一不可。

/unsubscribe?u=用户编号&k=邮件类型&s=签名

一个链接只能关掉这个人的这一类邮件。把用户编号或类型改掉一个字,签名就对不上,会提示“退订链接无效”。这一点我写了测试,也在本地手动换了一个伪造的签名试过。

打开链接不能直接退订

“一键退订”听上去应该是点一下就生效。但助手在设计里指出了一个陷阱,QQ 邮箱在检查链接是否安全的时候,会自动把邮件里的链接打开一遍。

如果打开链接就等于退订,那邮件刚到达,还没被任何人读过,这些链接就已经被邮箱“点”过了,大家的提醒会在不知情的情况下被全部关掉。

通俗比喻:替你验货的门卫

小区的门卫会把每个快递拆开检查一遍再放行。如果规定“拆开包裹就等于确认取消订单”,那所有人的订单都会在门卫手里被取消。

所以真正的“取消”动作,必须是收件人自己再按一下按钮,而不是打开包裹这个动作。

所以链接打开后只是一个确认页,上面有一个“关闭”按钮,点了才会生效。这个动作和在设置页里关掉是一样的,也会记进操作日志。

邮箱自带的退订按钮

很多邮箱会在邮件顶部显示自己的“退订”按钮。它们读取的是邮件头里的 List-Unsubscribe,如果同时带有 List-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058 规定的一键退订),邮箱会直接向这个链接发一个 POST 请求,不再让用户打开网页。

我给每封提醒和每周来信都加上了这两个邮件头。这个请求也要被服务端当作用户点了“关闭”来处理。

这里踩到一个隐蔽的地方。论坛为了防止跨站请求伪造,所有浏览器发来的写请求都要检查 Origin 是否是自己的域名,而邮箱发的这种 POST 根本不带 Origin。如果不处理,一键退订会被全部拒绝。

我把 /unsubscribe 这一个路径从 Origin 检查里豁免出来,改由链接里的签名来保证安全。其他页面不带 Origin 的写请求仍然会被拒绝,这一点我也在本地对着开发服务确认过。

不同邮箱是否显示自带的退订按钮,取决于它们自己的策略。我只在本地用不带 Origin 的 POST 模拟过,没有逐个邮箱实测。

内容太少,比不过别人的邮件

模板上线后,我收到了鼠洞的一封升级邮件,又对照着看了另一个论坛的升级邮件,总觉得自己的不如人家。助手帮我对比了一下,结论很明确,差在内容上。

我们的卡片里只有一句话,还和邮件标题一字不差,“已升级”这几个字在一屏里出现了两次。对方的邮件说了三件事,为什么升级、这代表什么、接下来可以做什么,每件事都带了链接。内容少,外框就显得重,一个带边框的圆角卡片里只有一行字,看上去像个空框。

改写后的升级邮件(设计草稿里的示例)

你开始在鼠洞参与讨论,也收到了群友的赞,升到了 L2 学鼠。

三个数字格子,分别是回复过的帖子、获赞和读过的楼层。

从这一级起,你可以发投票帖和抽奖帖了。

下一级 L3 硕鼠,还差几项,每项显示“已有数量 / 要求”,达到的打上勾。

按钮写成“看看升级进度”,而不是泛泛的“查看”。

我让助手把每类邮件改后的样子都写成例子给我确认,再动手。所有邮件统一遵守四条做法。

  • 卡片里不再重复一遍邮件标题。
  • 重要的数字放进数字格子,其余写成一行小字,是 0 的不写。
  • 按钮写具体动作,比如“去回复”。
  • 每封仍然不超过 4KB。

这样一来,几封邮件马上超了预算,升级到 L2 的一封有 5043 字节,升级到 L4 的有 4981 字节,回答被采纳的有 4035 字节。我看了体积的构成,发现模板源码里的缩进和换行占了很大一块。HTML 里连续的空白,显示出来只算一个空格,所以在最后输出之前,把连续空白合并成一个,每封邮件省下约 1.3KB。最大的一封降到了 3795 字节。

数字格子和列表也有细节要处理。几个格子宽度不一样,加上 table-layout:fixed 才一致;标签里带单位的“阅读时间(小时)”会折行,就把单位改成数字后面的小字,显示成“2.5 小时”;抽奖名单里奖项名字在窄列里被折行,就让左列不换行、顶端对齐。

另外有两处按钮和队列的小修改。按钮原来的外边距在不同邮件里不一样,统一成居中、上边距 24px。排队的邮件在每两封之间随机等一到两秒,验证码和测试邮件因为有人在等,不排队也不等。排队超过一天还没发出去的提醒,直接丢掉,因为过时的提醒已经没有用了。

这套做法的边界

这套模板主要是为 QQ 邮箱这个收件环境设计的。表格加内联样式在别的客户端上应该也能工作,但我只在自己收到的真实邮件和本地渲染的截图里确认过。一键退订的邮件头是否起作用,则取决于每个邮箱自己的支持。

体积预算 4KB 是我给自己定的,不是邮件规范要求的。它的作用是逼着我在“要不要再加一行数据”之前先想一想,这行数据是否真的帮到收件人。

评论