基于邮箱实现留言功能

2021-11-05 ⏳5.4分钟(2.1千字)

网站上线已经有几个月了,一直都没有留言功能。有朋友建议说添加留言或者讨论支持。我最早的想法是留言频很低,我也在个人页面留了邮箱地址。如果真有朋友需要讨论问题,可以发邮件。但实际的情况是国人很少使用邮件,也不方便。于是我考察了 Disqus 和一众基于 Github 的评论系统,它们共同的缺点是需要注册,而且有一定的隐私风险。它们的 CDN 在国内也不太稳定,影响用户体验。我希望找一个支持匿名评论的系统。最终找到了 isso。它使用 sqlite 保存评论数据,支持匿名评论,各方面都很契合我的需求。但是它用 python 开发,部署起来非常麻烦。最后,我想了一个比较取巧的方案:基于电子邮箱来实现留言板功能。今天就把思路和方案整理出来,分享给大家。

脑洞

评论或者留言系统要做完整,也非常复杂。像是树状结构、楼中楼、多级回复、点赞等等。但作为一个博客的留言系统,真有必要做这么复杂吗?我认为没有。博客的留言系统最核心的功能就是为读者和作者提供一个沟通的渠道,为读者和作者建立连接。所以说,如何展示评论、要不要支持引用和回复都在其次。最核心的功能就是读者的留言可以让作者尽快收到,作者也能很方便地回复读者的问题。从这个角度看,使用邮件沟通没有什么大问题。但是,邮件毕竟在国内不常用,所以我们得想办法降低留言的使用门槛。为此,我就打起了邮件系统中的 IMAP SMTP 的主意。

了解电子邮箱的朋友肯定听说中的 SMTP/POP/IMAP 这些术语。简单来说,SMTP 用于发送电子邮件,POP/IMAP 查看电子邮件。别人给你的电子邮箱发的邮件都保存在服务器上,你可以配置手机或者电脑的客户端来查看邮件的内容。客户端跟服务器通信用的就是 POP 或 IMAP 协议。POP 协议只能从服务器下载邮件,而 IMAP 协议则比较复杂,不但可以下载,还可以上传!什么🤔可以上传?苹果系统的笔记软件就支持将笔记保存到邮件服务器,用的就是 IMAP 的上传功能。那我们能不能将用户的留言也通过 IMAP SMTP 上传到邮箱呢?

使用 IMAP 协议需要作者在自己的 env 文件中配置邮箱的 IAMP 访问密码。该方案缺点有二。

SMTP 实现

为此,我让乐乎平台1利用 SMTP 协议,使用自平台的邮箱发送评论邮件。同时在邮件中设置 Reply-To Header 指定读者的邮箱信息,这样作者就能直接回复留言。

Go 语言官方支持 SMTP 协议2,而且支持 STARTLS,方便又安全。下面是发送邮件的示例:

auth := smtp.PlainAuth("", "user@example.com", "password", "mail.example.com")

to := []string{"recipient@example.net"}
msg := []byte("From: <root@nsa.gov>\r\n" +
              "To: <root@gchq.gov.uk>\r\n" +
              "Subject: Hey there\r\n" +
              "\r\n" +
              "Hey <3")
err := smtp.SendMail("mail.example.com:25", auth, "sender@example.org", to, msg)

有一个坑需要小心。调用SendMail时第一个参数需要加端口。构造auth时最后一个参数只写域名,不能加端口。不然就会报"wrong host name"错误。查了老半天。

IMAP 实现

IMAP 版本,不再推荐使用

于是我开始研究 IMAP 协议。讲道理这个协议比较复杂。我没有直接研究协议本身,而是试着搜索有没有 Go 语言的 IMAP 协议库。果然让我找到了3。go-imap 的 Client 对象支持通过Append()4方法向 IMAP 服务器上传邮件。上传之后,效果上等同于收到了一封新邮件,客户端会收到提醒。文档上直接附带了示例代码:

package main

import "github.com/emersion/go-imap/client"

func main() {
  // 登录 IMAP 服务器
  c, err := client.DialTLS("mail.example.org:993", nil)
  if err != nil { panic(err) }
  defer c.Logout()
  err := c.Login("username", "password")
  if err != nil { panic(err) }
  // 构造邮件内容
  var b bytes.Buffer
  b.WriteString("From: <root@nsa.gov>\r\n")
  b.WriteString("To: <root@gchq.gov.uk>\r\n")
  b.WriteString("Subject: Hey there\r\n")
  b.WriteString("\r\n")
  b.WriteString("Hey <3")
  // 上传邮件到收件箱 INBOX
  err := c.Append("INBOX", nil, time.Now(), &b)
}

构建 MIME 邮件

整个过程比较简单,不做赘述。但第二步构造邮件内容这个环节需要改造。示例中的邮件内容是英文:

From: <root@nsa.gov>\r\n
To: <root@gchq.gov.uk>\r\n
Subject: Hey there\r\n
\r\n
Hey <3

其中 From、To 和 Subject 都是规定好的,对应发件人、收件人和邮件主题。因为电子邮件协议是美国人发明的,早期并不支持中文,甚至连很多西欧的文字都不支持。为了解决这个问题,人们又引进了 MIME 协议。MIME 全称 Multipurpose Internet Mail Extensions,是一种扩展电子邮件的协议规定,通过 MIME 我们在电子邮件中加入 HTML表格、图片、中文,甚至是文件附件等内容。下面是一条带有中文的 MIME 邮件:

Mime-Version: 1.0
From: =?utf-8?q?=E5=BC=A0=E4=B8=89?= <z3@foo.com>
Subject: =?utf-8?b?5YWz5LqO5rab5Y+U?=
To: <hi@taoshu.in>
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Date: Fri, 05 Nov 2021 05:52:37 +0000

=E4=BD=A0=E5=A5=BD

https://taoshu.in/about.html

大家注意,这里多了一个 Mime-Version 头,用来指定 MIME 协议版本。有了这个字段,说明这是一封 MIME 邮件。

下面的 From 字段好像是乱码

=?utf-8?q?=E5=BC=A0=E4=B8=89?=

不要怕。开头和结尾是=??=,可以不管。第一个问号后面是 utf-8 表示后面的内容是用 utf-8 编码。再后面的?q? 表示后面的内容使用的是 quote 编码。quote 编码就是将进行内容的每一个字节转换成对应的十六进制的字符串表示。比如「张三」的 utf-8 编码是0xE5BCA0E4B889,所以转成 quote 编码就是=E5=BC=A0=E4=B8=89。这里的等号是 MIME 规定的。有的地方也用百分号。

再看 Subject 字段。utf-8 后面是?b?,表示后面的内容经过 base64 编码处理过。其他没有中文的地方则跟之前的英文邮件没有区别。再下面就是 Content-Type 和 Content-Transfer-Encoding 了。这部分跟 HTTP 协议的规定很像,规定了邮件正文的编码格式和传输格式。其中 Content-Type 是text/plain; charset=utf-8,说明正文是 utf-8 编码的纯文本。而 Content-Transfer-Encoding 是 quoted-printable,说明邮件正文会被转换成 quote 编码。

以上就是使用邮件发送非 ASCII 内容的大致原理。我们当然不需要自己去处理这么复杂的编码操作。我找了一个开源库5,整个过程被简化为:

m := enmime.Builder().
  From("张三", "z3@foo.com").
  To("涛叔", "hi@taoshu.in").
  Subject("邮件摘要").
  Text([]byte("邮件正文"))

我把 go-imap net/smtp 跟 enmime 结合起来,就可以实现通过 IMAP 协议自己给自己发邮件(评论)。

留言表单

最后我们准备一个表单,用于接收用户的留言内容,表单的字段有:

<form onsubmit="event.preventDefault(); reply()">
  <textarea name="content" placeholder="留言" required></textarea>
  <input name="email" type="email" placeholder="邮箱(可选)">
  <input name="name" type="text" placeholder="名字(可选)">
  <input type="submit" value="提交">
</form>

表单使用 AJAX 提交,源码如下:

function reply() {
  var data = new FormData(event.target);
  data.append("subject", document.title);
  fetch('/+/mail', {
    method: 'POST',
    body: new URLSearchParams(data),
  })
  .then(resp => alert("留言提交成功"))
  .catch(error => alert("接口报错:"+error));
}

服务端收到用户提交的数据后会自动生成一封 MIME 邮件,然后通过 IMAP 上传到收件箱。这样我就能第一时间收到通知。如果用户自己留了邮箱地址,我也可以直接回复邮件。

总结

以上就是使用 IMAP 实现留言板的全部内容。大家现在就可以给我留言。这种方式不需要注册帐号,特别对那些特别注意隐私保护的朋友比较友好。而且,更重要的时,作者可以通过配置邮件客户端即时接收留言,非常方便。但这种方式不支持实时展示用户留言,算是一个遗憾。但我认为,博客的评论应该以服务读者与作者的沟通为主,读者之间的讨论不是主要矛盾。先上线看看效果。不展实时展示评论内容的另一个好处则是劝退那些想发广告信息的人。因为大家看不到,也就没有发的必要了。大家怎么看呢?欢迎留言讨论。


  1. https://lehu.in↩︎

  2. https://pkg.go.dev/net/smtp↩︎

  3. https://github.com/emersion/go-imap↩︎

  4. https://pkg.go.dev/github.com/emersion/go-imap@v1.2.0/client#Client.Append↩︎

  5. https://github.com/jhillyerd/enmime↩︎