电子邮件前端开发指南

此文是我作为团队分享所作。

前言

作为 1971 诞生的电子邮件(email),在当前的互联网环境依然发挥着无可替代的作用。虽然国内使用的相对较少,人们更倾向于使用即时通讯消息,甚至即使倒退十几年,我们对短信的偏好也远胜于邮件,但在国外依旧是主流的信息交互方式,也因此保留了时至今日依旧是主流营销渠道之一的 EDM(Email Direct Marketing,电子邮件营销)。而 EDM 也是甚少与前端有关的邮件术语之一,大概仅仅只是因为邮件含有 HTML 的缘故吧。

基础知识

邮件与网页的不同

没有尝试写过邮件的开发可能误以为邮件和网页并无二致,但实际上邮件作为纯书面的数码载体,仅仅只支持一些最基础的 HTML。当然随着时代的进步,邮件客户端支持的各种特性也在越来越丰富,但就像当年 IE 6给前端留下的伤痕一样,邮件客户端的兼容性问题远比浏览器更为严重。根据接收邮件客户端的不同,不仅对 HTML 的支持度有很大的差距,相同客户端在不同系统平台的表现也是不同的,这就使得兼容性成为编写邮件的沉重负担。

在加上几个历史问题(Gmail 和 Outlook 的几次版本更新),造成了苦涩的结果:传统邮件几乎都由 table 标签编写而成。有一种瞬间回到 20 年前的感觉。不仅是 table,并且还大量使用了过时的 HTML 属性而非 CSS 样式。其目的就是提供最大的兼容性,以适应各种各样的繁多的客户端。

对于从不写邮件的前端而言,邮件只是普通 HTML 的观念是普遍的,且错误的。但反过来,长时间编写邮件,那么另一种错误观念也会比较普遍:邮件只能用 table 标签写。而事实上,邮件的编写是对于兼容客户端范围的一种平衡和取舍,这也是为什么,当我们打开邮件查看源码,会发现 95%以上的邮件由 table 编写,但依然还有像 codepen 这样完全不在乎兼容而使用 div 的例子的原因。

本篇的内容聚焦于传统 table 邮件的编写。

邮件的格式

以 QQ 邮件为例,选择显示邮箱原文,就可以看到邮件的原始代码数据。

Date: Sun, 25 Jul 2021 03:26:55 +0300 (MSK)
From: Codeforces@codeforces.com
To: xxx@xxx.com
Message-ID: <1350159521.634421.1627172815667.JavaMail.Codeforces@codeforces.com>
Subject: Codeforces Global Round 15 (Rated for Everybody, 50 Prize
 T-Shirts!)
MIME-Version: 1.0
Content-Type: multipart/alternative;
	boundary="----=_Part_634420_1282013202.1627172815667"

------=_Part_634420_1282013202.1627172815667
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable



Attention! The round starts on Sunday, July, 25, 2021 14:35 (UTC).  Hello, =
iifksp.

Thanks XTX Markets, we are holding Codeforces Global Rounds.
.......

------=_Part_634420_1282013202.1627172815667
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit

<div style="float:right; margin:0 1em 1em 1em; text-align:center;max-width:50%;">
.......

邮件原文比较简单,主要的字段就是:发送方 from,收件人 to,标题 subject,纯文本内容 plain text,以及 html 内容。邮件通常需提供两份内容,一份为纯文本,另一份为 HTML。不同的邮件客户端会根据自己支持的情况,来展示相应的内容。这里的基础的纯文本在邮件的前部,是因为不希望给老旧客户端带来解析的压力。这点就能看出,邮件在兼容方面的思虑远高于特性支持。

实际使用中,我们看到很多技术类邮件都只有纯文本,即便现在几乎找不出人们常用的不支持 HTML 的邮件客户端,但作为基础信息传递的纯文本信息仍无处不在。而很多营销类邮件则反之,只提供了 HTML 的版本。

即便提供了 HTML 的版本,纯文本的部分也并非毫无用处,纯文本常被用来提取摘要信息,并且几乎所有的最佳实践都会提到,不含纯文本的邮件更容易进垃圾箱。

在 QQ 邮箱中,当选择使用 HTML 代码模式,贴入我们写好的 HTML 代码,那么发送的时候,系统会抽取其中的文本内容作为邮件的纯文本部分。但是,如果是我们自己通过代码创建发送管道,入参通常只有 HTML,发出去的邮件也仅只有 HTML。

关于更多格式细节,可以参看相关 RFC:

数据追踪

邮件由于不能运行 js,所以所有的追踪都要依赖于链接跳转和图片加载。通过外联图片向服务器发起请求并携带参数。通过中转链接记录用户点击链接的行为。

在真实场景中,往往中转重定向存在好几层,这是需要警惕的问题。重定向过多不仅使得打开速度缓慢,更极大的加大了邮件的代码量。会遇到各种无法解决的问题,比如之后会提到的 Gmail 的尺寸限制。

编写指南

国内最早的邮件编写指南在我的印象里,应该是出自阿里的口碑网 UED 之手。约 12 年前那个还没有被之后雪藏而今又再拿出来的口碑网。我在 2011 年前后写的一些文章有很大部分受教于口碑 UED 的博文,不过不久口碑解散(虽然现在又复活),文章在网上也未存任何痕迹。

后来因为工作关系又和邮件有了些交集,遇到最搞笑的是其他部门的人找了些网上的资料发给我看让我写个邮件,结果还是自己的文章,真就是自己教自己写邮件...可见,国内圈子之狭小,以及邮件相关文章之匮乏。

下面就循序渐进,讨论一些常用的和好用的邮件编写技巧。由于这部分过于琐碎,组织和编写需要拉长时间,发布本文后还会有持续的更新和整理。

特性支持

首先需要了解的是当前邮件客户端对 HTML 特性的普遍的支持情况。大体的情况可以查阅 caniuse.email 这个开源项目的总结,可以对特性支持有一个大概的印象。

背景图可以单独拿出来说一下,参看 邮件客户端背景图支持列表,这是由 emailonacid 总结的比较全的表格,涵盖了 100 种邮件客户端的背景支持情况。这么完整的表格恐怕也只有这类专业的邮件业务公司才能做出来,且更新日期也已经是 2 年以前。

明确所编写的邮件的支持范围是一个非常重要的步骤。当然对于商业邮件,总希望能支持最多的客户端。这里最为重要的一个矛盾就出在“背景图”上。简要摘录一下 Outlook 的背景图支持:

Background="image.png"
Outlook 2003
Outlook 2007 x
Outlook 2010 x
Outlook 2011 (OS X)
Outlook 2013 x
Outlook 2016 macOS
Outlook 2016 (Windows 7) x
Outlook 2019 (Windows 10) x

虽然 Outlook,在 MacOS 上大部分版本支持背景图,但 Outlook Windows 直到 2019 版本都不支持,这令人大跌眼镜。所以通常的邮件编写,是不能有背景图的,除非考虑放弃一些客户端。像 Outlook 这种,在用户层可能使用并不多,但在老板和同事层却广为使用,给邮件编写造成了不小的麻烦。

编写基础

大部分情况下,我们都遵循现有 HTML 的编写法则,然后大刀阔斧地做出这些限制:

  • 几乎由 table 标签搭建
  • 样式全部为行内样式(),且视邮件客户端能力,很多行内样式也是不支持的
  • 没有绝对定位,浮动等 web 常见排版
  • 没有任何 JavaScript 功能
  • 大量的旧有属性和早期私有属性的使用
  • 低效的调试速度

然后再加上一些基础原则(已针对当前客户端情况做了删减):

  • 使用 td 作为内容的容器,少添加 p,div 等元素
  • 在 td 上使用样式,而不是在 table 或 tr 上
  • 如果有旧 html 属性和 css 样式作用相同,可以一起加上,比如 对于 img 标签 border="0" 和 style="border:0;"都可以加上
  • 可以使用背景图,但只能渐进使用,纯色背景作为保底。所以不能在背景图中含有重要信息和暗示
  • 图片指定宽高属性和 alt,因为邮件客户端很可能拦截图片,此时如果没有宽高和 alt,邮件本身将会错位
  • 行内样式里,如果指定字体,则每一处叶子节点的 td 标签都需要指定
  • line-height 只作用于文字。如作用于图片将使图片拼接存在间隙
  • 间距高度最好用实体的 td 标签的高撑起来,不要使用垂直 margin 值

对大部分前端来说,这就好比用惯了 ES6,然后现在让他只用 ES5 一样难受,然后惊呼,怎么还会有这么古老的东西!

邮件的套路结构是 table 套 table,最常见到的结构套路就是一个 table 单元,并逐级嵌套:

<table align="center" width="100%" cellPadding="0" cellSpacing="0">
  <tr>
    <td>内容货继续套table</td>
    <td>内容货继续套table</td>
  </tr>
</table>

布局与响应式

由于邮件的宽度通常为 600 或者 640,加上技术限制,布局其实无外乎 2 列和 3 列两种。但再锁死了样式后,我们要么使用 table 的多列得到 2 列或者 3 列:

<table align="center" width="100%" cellPadding="0" cellSpacing="0">
  <tr>
    <td>第一列</td>
    <td>第二列</td>
    <td>第三列</td>
  </tr>
</table>

要么使用匪夷所思的古老 align 属性:

<table align="center" width="600" cellPadding="0" cellSpacing="0">
  <tr>
    <td>
      <table class="col" align="left" width="290" cellPadding="0" cellSpacing="0">
        <tr>
          <td>左边</td>
        </tr>
      </table>
      <table class="col" align="right" width="290" cellPadding="0" cellSpacing="0">
        <tr>
          <td>右边</td>
        </tr>
      </table>
    </td>
  </tr>
</table>

这两种方式存在一些差异,主要区别与对响应式的支持,以及间距的控制上。

邮件也可以作响应式。不是指做两块内容,然后通过 CSS 在不同宽度设备里做隐藏展示,而是和 Web 页面一样缩拉宽度和改变布局,但限于结构,只能简单的由多列变成一列。

在客户端支持响应式代码的前提下,比如 iOS 的默认客户端,将响应式代码写道 html 的 header 是有效的。针对上面代码的例子:

@media screen and (max-width: 700px) {
  .col{width: 100% !important;}
}

这会将两个列的宽度强制到 100%宽,使得每一列都独占一行。

而 3 列的响应式就更诡异,虽然向 iOS 的客户端支持响应式,但是如果将三个 td 放大到 100%是没有效果的,有趣的是,虽然对 td 无效,但对 th 有效。所以如果要做一个三列响应到单列的布局,需要像下面这样处理(这也可能随着客户端支持度的提高而变化):

<table align="center" width="100%" cellPadding="0" cellSpacing="0">
  <tr>
    <th class="col" width="33%">第一列</td>
    <th class="col" width="33%">第二列</td>
    <th class="col" width="33%">第三列</td>
  </tr>
</table>

@media screen and (max-width: 700px) {
  .col{width: 100% !important;}
}

在多数情况下,我都会建议需求方不要做邮件的响应式,而是让客户端直接缩放邮件本体。这种情况下,尽量缩小邮件的宽度会很有帮助,比如 600 就比 640 要合理,缩小后字体不至于太小。

响应式所耗费的测试和编写时间,是非常多的。而在稳定度上取决于编写者的经验,所以未必能加强用户体验。

测试与调试

邮件的测试调试都是相对较为麻烦的,多种客户端,加上多种平台,再加上邮件原文是编码过的,我们只能发邮件然后才能查看最终效果,比浏览器是要麻烦多了。当然邮件的结构相对简单,但如果一个修改需要查看 10 多个客户端效果,也是够累人了,所以经验多一些速度也会快很多。

我们可以通过大部分邮箱服务商提供的代码发送功能测试我们的 html 编写结果。

当然也可以用自己的发送管道发出邮件。比如 Nodejs 可以用 Nodemailer 来构建并发送测试邮件。在其构造方法里也清晰的展现了邮件信息的几个要素:

let info = await transporter.sendMail({
    from: '"Fred Foo 👻" <foo@example.com>', // sender address
    to: "bar@example.com, baz@example.com", // list of receivers
    subject: "Hello ✔", // Subject line
    text: "Hello world?", // plain text body
    html: "<b>Hello world?</b>", // html body
  });

标题摘要空白

如果如先前所说,在发送邮件是仅包含 HTML 内容,那么邮件的摘要部分很可能出现意想不到的内容,比如页头的导航条和 logo。

如果不能补上纯文本,那解决方案就是在 HTML 页头之前再插入一段不可见的摘要内容:

<div style="display: none; font-size: 1px; line-height: 1; max-height: 0; max-width: 0; opactity: 0; overflow: hidden;">纯文本邮件摘要内容</div>

并且为了避免摘要太短而让客户端依旧截取到了正文不希望出现在摘要中的内容,惯例上还会填充这样的空白字符:

&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
.......

Gmail 102K 问题

Gmail 作为市场占有大户,每个举动都影响深远。Gmail 存在一个最大邮件内容的限制,大小为 102K,这个大小是指邮件原文的内容部分,并不包含外联的图片。超出这个大小的内容,将无法在 Gmail 单屏里完整展示,部分内容会被作截断处理,然后提供访问所有内容的链接跳转————这,无疑会极大的影响邮件的展示效果和点击。

102K 说小也不小,但含有多重重定向数据追踪的链接是非常长的,如果页面充斥链接,或者平铺很多产品,或者嵌套过多层级的标签,都会轻而易举地超过这个界限,最关键的是还没有什么解决方法。如果不想受到截断影响,就只能在邮件原文里优化和平衡。

邮件与 TypeScript

虽然现在绝大部分前端代码推荐使用 TypeScrtipt,但邮件编写还是建议使用 js。并不是用不了,而是当使用 ts 后会出现很多类型缺失的提示,而这些废弃的属性或者写法,则根本没有与之对应的 ts 类型文件。为了拯救开发者颇为值钱的头发,请不要头铁尝试用 ts 写邮件,这是通往地狱的快速通道。

关于网易邮箱

国内不重视邮件,致使像网易邮箱这种占有很大市场份额的客户端,对响应式代码的支持度都非常糟糕,而同样的问题在QQ邮箱中就不存在。比如,网易会非常粗暴地在邮件的media query代码前加上自己的类名 .netease_mail_readhtml 进行样式限制,使得原本设计的响应式失效:

.netease_mail_readhtml .hotel-card-list-responsive-edm{width:100% !important;}
.netease_mail_readhtml @media only screen and (min-width: 640px) {  
    .hotel-card-list-responsive-edm{width:640px !important;}
.netease_mail_readhtml }
.netease_mail_readhtml @media only screen and (max-width: 500px) {  
    .trip-mail-fluid {width: 100% !important;}
.netease_mail_readhtml }

所以如果网易邮箱也是目标客户端之一,那么需要斟酌是否需要做响应式设计,或者做好fallback的预期。

待整理杂项

  • naver Web 存在最大宽度居中问题

题外话

我们的邮箱每天都充斥着垃圾邮件,邮件直接触达用户,所以邮件的发送必须受到严格的约束。

大量发送邮件,不仅仅让用户反感,降低营销信息的敏感度,更重要的是失去用户(退订)。

比如,京东的营销邮件频次约为三天一次。私以为是个不错的平衡。