基于IMAP邮箱开发网站留言功能

2021-11-05 创作不易,请勿屏蔽广告🙏 g

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

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

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

于是我开始研究IMAP协议。讲道理这个协议比较复杂。我没有直接研究协议本身,而是试着搜索有没有 Go 语言的 IMAP 协议库。果然让我找到了go-imap。go-imap 的 Client 对象支持通过Append()方法向 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)
}

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

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 内容的大致原理。我们当然不需要自己去处理这么复杂的编码操作。我找了一个开源库enmime,整个过程被简化为:

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

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