Hosting My Own Newsletter

Matthias Endler

自托管我的邮件简报

原文由 Matthias Endler 发布,订阅该博客

这个博客的邮件简报我做了好几年,但有很长一段时间一封邮件都没发过。这篇文章讲的是我是如何终于让它重新运转起来的,以及一路上学到了什么。

先提前说明一下,因为这点曾引起过一些误会:我说的“自托管”是指我没有使用现成的简报平台。订阅的后端和用来发送每期的命令行工具都是我自己的,每期内容本身也只是 git 仓库里的 markdown 文件。但发送环节我依然用 Plunk 来做后端(所以 SES、退信、屏蔽名单、退订页面这些麻烦事都不用我操心)。Plunk 本身是开源的,我完全可以自己部署一套,但送达率这块的坑实在太多,我很乐意花钱让别人来管。🙃

Tinyletter 时代

旧版 Tinyletter 首页,如今只剩下一个凄凉的 404。
旧版 Tinyletter 首页,如今只剩下一个凄凉的 404。
来源:Wayback Machine

多年来,我的方案就是在网站上放一个指向 Tinyletter 的小表单,那是一个专注于写作者的小型邮件简报服务。我喜欢它的简单——完全不用操心邮件送达率、退信率、屏蔽名单、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 测试、互动评分、追踪像素。整套话术都不对味。我不想搞什么营销活动,我只想发邮件
  • 对极客不友好。 不支持 markdown,没有 CLI,也没有让人想用的 API。所有操作都得在一个为营销团队设计的网页后台里完成。
  • 不是开源。 如果下一个 Tinyletter 再关门,我希望能不受影响地继续下去,而不是又得迁移一次。
  • 默认追踪! 打开追踪、点击追踪,每个页脚里都塞着像素点。我根本不想知道谁打开了什么。我只想写,你愿意看就看(不看也罢),就这么简单。

迁移到 Fly.io

老有人问我简报什么时候回归,于是我在 fly.io 上临时拼凑了一个方案:一个小小的 Rust API,一份存着订阅者的 CSV 文件,再加上网站上的订阅入口。当时的想法是发送的事以后再说,至少先让大家能重新订阅。

然后这份名单就一直在那儿闲置着。

结果发现,光是“冷名单”本身就是个问题。当你时隔很久才给一群很久没收到你邮件的人群发邮件时,邮件服务商会起疑,你很可能被标记为垃圾邮件。一不小心,自己的简报反而会反噬自己。

寻找发送服务

这无疑是最艰难的一环。我试过 ResendPostmarkSendGridMailgunAmazon SES,还有很多别的。要么对小简报来说太贵,要么 API 很难用,要么不符合 GDPR,要么就是过于复杂。

就在快要放弃时,我找到了 Plunk。它是开源的,价格随名单规模浮动,API 也很顺手。它帮我搞定了那些我不想操心的送达率工作(SES 集成退信处理屏蔽名单托管的退订页面)。我现在是它的付费用户,没有任何利益关联,只是真心觉得好用。

我还给他们提了一个小小的贡献,十分钟就被合并了。这让我真切地感受到自己是社区的一员。

第一期真正意义上的简报发给了 1000 多位久未联系的订阅者。我本来已经做好迎接大量退信的准备,结果一切顺利。退信率大约 1%,退订的寥寥无几,也没有遇到任何送达问题。太让我惊喜了!

我没搞什么花活:没有分批发送,没有缓慢预热,也没有精心设计的标题。我一次性全部发出,让 Plunk(底层其实是 SES)通过退信处理自动清理掉那些明显失效的地址。唯一做的一件事,是在第一期的开头加了一段简短而坦诚的自我介绍——大概是说“嗨,你曾因为读过我的一篇博文而订阅,抱歉这么久都没动静”——我觉得正是这段话很大程度上让退订率保持在了低位。

费用方面,给全量名单发一次大概只要 $1。对于我这种不定期发送的简报来说,几乎可以忽略不计。

Plunk 的后台面板,展示了群发概览和送达率报告。如你所见,我并没有追踪谁打开了邮件。
Plunk 的后台面板,展示了群发概览和送达率报告。如你所见,我并没有追踪谁打开了邮件。
来源:Plunk

这才像家!

我意识到我可以把每期简报写成文件夹里的纯 markdown 文件,用版本控制管理,其他事情都交给一个小小的命令行工具。这才是让我感到自在的地方。就我、一杯热可可、编辑器、终端和 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

这个命令行工具叫 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> 这种形式——按语义化版本命名,主版本号永远卡在 0,算是对那些永远到不了 1.0 的项目开的一个小玩笑。

send status 会按每次群发展示送达情况,退信率单元格会根据 SES 的阈值标上不同颜色,还能看到每天的退信和退订数,让我能及早发现问题。

send lint 会在发布前用 lychee 检查每期中的所有链接。我本身就是 lychee 的维护者,在这里自产自用是再自然不过的选择,相比完全没有链接检查的旧版 Tinyletter 网页编辑器,这在体验上是个不小的提升。

网站上的订阅表单会 POST 到跑在我服务器上的那个小小的 subscribe 服务上。它会校验邮箱,丢弃所有填了蜜罐字段的请求,然后以 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,不过我还没试过。

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

评论