Hosting My Own Newsletter

Matthias Endler

自托管我的邮件通讯

我在这个博客上开了邮件通讯好多年,却有很长一段时间一封邮件都没发过。这篇文章讲述了我最终如何让它重新运转起来,以及这一路上的收获。

先简单说明一下,因为这点曾引起一些误解:我所说的“自托管”是指我没有使用邮件通讯平台。订阅后端和我用来发送每期的 CLI 都是我自己的,每期内容本身就只是 git 仓库里的 markdown 文件。我仍然把 Plunk 当作发送后端(所以 SES、退信、suppression lists(抑制名单)和退订页面都不用我操心)。Plunk 本身是开源的,我也可以自托管,但 deliverability(送达率)方面的边界情况足够多,我很乐意付费让别人来运维。🙃

Tinyletter 年代

旧 Tinyletter 着陆页,如今只剩一个可怜的 404。
旧 Tinyletter 着陆页,如今只剩一个可怜的 404。
来源:Wayback Machine

多年来,我的配置就是网站上的一个小表单,指向 Tinyletter——一个专注于作者的小型邮件通讯服务。我喜欢它的简洁。我从来不用考虑邮件的 deliverability、bounce rates(退信率)、suppression lists、SPF、DKIM、DMARC 这些东西。写点东西,点一下发送,大家就能收到。

Tinyletter 撰写页面,展示了界面的简洁性。
Tinyletter 撰写页面,展示了界面的简洁性。

它就是能用。然后 Tinyletter 关停了。

补充一点历史:Tinyletter 由 Philip Kaplan(菲利普·卡普兰) 于 2010 年创建,据说是在2010 年 10 月 31 日那个周日一天之内写成的

一年后它被 Mailchimp 收购,并悄然成为那些想写个人邮件通讯、又不想操心漏斗、分组或 A/B 测试的作者们的默认选择。

然后在 2023 年底,Mailchimp(现为 Intuit 旗下)宣布将关闭它。官方说法是他们的“业务重点已经转移”,并且他们“正全力聚焦于打造服务营销人员、帮助小企业成长的工具”。作者们从来都不是他们的核心客户。

Mailchimp 在 2023 年底发布的关停公告。
Mailchimp 在 2023 年底发布的关停公告。
来源:EmailOctopus

就在 Tinyletter 于 2024 年 2 月 29 日关停前,我对订阅者名单做了最后一次备份,但还没想好接下来怎么处理它。

抵触期

到了这个阶段,我开始排斥使用第三方服务的想法。同样的故事可能会再次上演。

我还是把所有选项都看了一遍,结果全都否决了:

  • 太贵了!大多数服务按联系人数收费,默认你是在运营商业漏斗,而不是给人写信。
  • 太偏营销了!模板、拖拽式编辑器、A/B 测试、engagement scoring(互动评分)、tracking pixels(追踪像素)。整套话术都不对味。我不想搞什么营销活动;我只想发邮件
  • 对极客不友好。没有 markdown,没有 CLI,也没有我真正想用的 API。一切操作都在为营销团队打造的网页仪表盘里完成。
  • 不是开源。如果下一个 Tinyletter 也关了,我希望还能继续做下去,而不必再次迁移。
  • 默认就追踪!默认开启打开追踪、点击追踪,每个页脚里都有像素。我不想知道谁打开了什么。我想写,你读(或者不读),就这么结束。

迁移到 Fly.io

不断有人问我邮件通讯什么时候回归,于是我在 fly.io 上拼凑了一个临时方案。一个小型的 Rust API、一个存着订阅者的 CSV 文件,以及一个通过网站订阅的入口。本想先解决订阅,发送的事以后再说,至少先让人能注册。

随后名单就一直晾在那里。

结果,名单放久了本身就是个问题。当你时隔很久才向一群很久没收到你消息的人发邮件时,邮件服务商会起疑,你很可能会被标记为垃圾邮件。转眼间,你自己的邮件通讯就可能反过来对付你。

寻找发送服务

这是目前为止最艰难的一环。我研究了 ResendPostmarkSendGridMailgunAmazon SES 等等。它们要么对一个小型邮件通讯来说太贵,要么 API 糟糕,要么不符合 GDPR 规定,要么过于复杂。

我快要放弃时找到了 Plunk。它是开源的,定价随名单规模浮动,而且 API 用起来不让人抓狂。它帮我处理了我不想操心的 deliverability 工作(SES 集成bounce handling(退信处理)suppression listhosted unsubscribe pages(托管退订页面))。现在我是付费用户。我和它没有利益关联,只是个真心满意的用户。

我甚至给他们提了一个小贡献,他们十分钟就合并了。这让我感觉自己真的成了社区的一员。

第一期真正意义上的邮件通讯发给了上千个久未联系的联系人。我已经做好了迎接大量退信的准备,结果一切顺利。Bounce rate 大约 1%,退订的很少,也没有遇到 deliverability 问题。哇!

我没搞什么花活:没有分批发送,没有缓慢预热,也没有精心设计的主题行。我一次性全发了出去,让 Plunk(底层其实是 SES)通过 bounce handling 自动剔除明显无效的地址。我唯一做的一件事,就是在第一期的开头加了一段简短而坦诚的自我介绍——大概是“嗨,你曾因为读过我的一篇博客文章而订阅,抱歉这么久没联系”——我觉得正是这段话让退订率保持在了低位。

从成本来看,给全量名单发一次大约要花 1 美元。对于一封不定期发送的邮件通讯来说,这几乎可以忽略不计。

Plunk 仪表盘,展示了邮件活动概览和送达率报告。如你所见,我不会追踪谁打开了我的邮件。
Plunk 仪表盘,展示了邮件活动概览和送达率报告。如你所见,我不会追踪谁打开了我的邮件。
来源:Plunk

这才像家!

我意识到,我可以把每期内容写成文件夹里的纯 markdown 文件,纳入版本控制,其他事情都用一个小小的 CLI 来完成。这才是我自在的地方。就我、一杯热可可、编辑器、终端和 git。写作和我之间再也没有网页仪表盘挡着了。

整个东西都放在一个仓库里:

newsletter/
├── issues/         # one .md per edition (1.md, 2.md, ...)
├── send/           # the CLI I run locally
└── subscribe/      # tiny HTTP service behind the website signup form

这个 CLI 叫 send。它能做这些事:

$ send help

Usage: send <COMMAND>

Commands:
  new      Create a new issue file and open $EDITOR
  list     List local issues
  lint     Check links in an issue (or all issues)
  test     Send a test email to myself  
  publish  Publish the issue to all subscribed contacts
  status   Show contact-list and deliverability report
  prune    Delete unsubscribed contacts

send publish 2 会先给我看预览、收件人数,并弹出一个 y/N 确认提示,确认后才会真正发送。主题行会自动生成为 corrode v0.N.0 # <topic>——采用 semver 风格,主版本号永远卡在 0,算是对那些永远到不了 1.0 的项目开的一个小玩笑。

send status 会按每轮 campaign 展示 deliverability 情况,其中 bounce-rate 单元格会根据 SES 阈值做颜色标记,还会显示每日退信和退订数据,让我能及早发现问题。

send lint 会在发布前用 lychee 检查每期内容里的所有链接。我是 lychee 的维护者之一,所以在这里“自产自用”是很自然的选择,相比完全没有链接检查的旧 Tinyletter 网页编辑器,体验也好了不少。

网站上的订阅表单会 POST 到跑在我服务器上的那个小小的 subscribe 服务。它会校验邮箱,丢弃那些填了 honeypot 字段的请求,然后以 subscribe-requested 事件 POST 给 Plunk。Plunk 会以未订阅状态创建联系人,并通过它的 Action 工作流发送事务性确认邮件。只有当收件人点击链接后,Plunk 才会将其切换为已订阅1。没有回传到我这边的 webhook,没有回调,页面上也没有 JavaScript。我只需推送到 git,服务器检测到变更后构建并运行 server crate,新版本就上线了。运行中的服务几乎不占用任何 CPU 或内存。

稍微说说 DNS

Plunk 需要在 DNS 中配置三样东西来替我的域名发信:一条 SPF 记录(声明 SES 可以代该域名发信)、一个 DKIM 密钥(让 SES 可以为外发邮件签名),以及一条回邮路径 MX 记录(让退信能回到 Plunk 可读取的地方)。这三条都放在子域名下。别担心,Plunk 会明确告诉你怎么配置,你只需把记录复制粘贴到 DNS 服务商的控制台即可。

有一点千万别忘了:不要在域名的顶点添加 Plunk 可选的入站 MX。否则会把本该由你现有收件服务(对我来说是 mailbox.org)处理的邮件抢走,回复就收不到预期的地方了。

一个小插曲

我忘了 From: 地址如果想让回复正常工作,必须是一个真实存在的邮箱。第一期是以 [email protected] 发出的,而这个邮箱根本不存在。一位好心的读者(嗨,Kevin!)回复打招呼时收到了退信,于是他把退信通知转发给我提醒我。我在 mailbox.org 上创建了别名之后,回复就都能正常收进收件箱了。

一个名单就够了,无需两个

顺手,我还把原来 endler.dev 的邮件通讯和 corrode.dev 的那个合并成了一个名单。反正一直都是我在写,维护两套并行的配置实在没什么道理。同一个人敲键盘,受众也大多重叠,却要做两倍的维护。

合并本身波澜不惊:我有一个从 Tinyletter 导出的 CSV(原来的 endler.dev 名单),还有一个来自 fly.io 服务的 CSV(corrode.dev 上线后开始收集的名单)。格式相同。两者都导入 Plunk 后去重完全不是问题。在第一期里,我也把定位说得很清楚(一份涵盖我所有写作的邮件通讯),这样就没人会疑惑自己到底订阅了什么。2

往后,就只有这一份邮件通讯了。如果其中有内容不适合你,随时可以退订,再也不会收到我的邮件。完全没关系。

想对你说的话

如果你也一直在考虑自己动手做:就去做吧。自托管真的比以前容易多了。现在几乎每个环节都有优秀的开源服务。一般来说,自己动手做点小东西是真正理解它们、并持续掌控关键部分的最佳方式之一。这本身就值得另写一篇博客,如果你想看,请告诉我。

如果你想瞥一眼那个(有点粗糙的)仓库,给我发封邮件,我会把链接发给你。其实没什么特别的,但如果你好奇它是怎么运作的,我很乐意分享。或者等我再整理一下、正式开源——不过那可能还得再拖几年。

最棒的是,你现在就可以通过填写下面的表单、订阅邮件通讯来亲自试试我的这套配置!

  1. 点击确认是为了符合 GDPR 规定,这部分由 Plunk 帮我处理。

  2. 关于备份:Plunk 保存着权威的订阅者名单,我随时可以导出为 CSV。我想他们也为此提供了 API,但我还没试过。

原文由 Matthias Endler 发布

本文章由 muse-spark-1.2-contributor 进行翻译